From 850c13777c74a254649133d6ef8919fa4bf7aa1f Mon Sep 17 00:00:00 2001 From: Steffen Skui Date: Tue, 21 Jul 2026 21:23:34 +0200 Subject: [PATCH] AHDX: Apple Health exporter with SQLite + Grafana dashboard (MIT) --- .dockerignore | 5 + .env.example | 7 + .gitignore | 13 + Dockerfile | 16 + LICENSE | 21 + README.md | 142 ++ app.py | 481 +++++ db.py | 730 ++++++++ docker-compose.yml | 51 + docs/plan.md | 173 ++ docs/screenshots/README.md | 9 + grafana/dashboards/apple-health.json | 1589 +++++++++++++++++ .../provisioning/dashboards/dashboards.yml | 13 + grafana/provisioning/datasources/ahdx.yml | 17 + jobs.py | 68 + parser.py | 273 +++ requirements.txt | 1 + scanner.py | 67 + schema.sql | 116 ++ static/style.css | 159 ++ static/vendor/leaflet/leaflet.css | 661 +++++++ static/vendor/leaflet/leaflet.js | 6 + templates/auth.html | 42 + templates/base.html | 54 + templates/browse.html | 39 + templates/dashboard.html | 50 + templates/databases.html | 69 + templates/ecg.html | 22 + templates/ecg_detail.html | 31 + templates/import.html | 54 + templates/includes/status.html | 11 + templates/route_detail.html | 34 + templates/routes.html | 20 + templates/trends.html | 64 + 34 files changed, 5108 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.py create mode 100644 db.py create mode 100644 docker-compose.yml create mode 100644 docs/plan.md create mode 100644 docs/screenshots/README.md create mode 100644 grafana/dashboards/apple-health.json create mode 100644 grafana/provisioning/dashboards/dashboards.yml create mode 100644 grafana/provisioning/datasources/ahdx.yml create mode 100644 jobs.py create mode 100644 parser.py create mode 100644 requirements.txt create mode 100644 scanner.py create mode 100644 schema.sql create mode 100644 static/style.css create mode 100644 static/vendor/leaflet/leaflet.css create mode 100644 static/vendor/leaflet/leaflet.js create mode 100644 templates/auth.html create mode 100644 templates/base.html create mode 100644 templates/browse.html create mode 100644 templates/dashboard.html create mode 100644 templates/databases.html create mode 100644 templates/ecg.html create mode 100644 templates/ecg_detail.html create mode 100644 templates/import.html create mode 100644 templates/includes/status.html create mode 100644 templates/route_detail.html create mode 100644 templates/routes.html create mode 100644 templates/trends.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b8d23df --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +data/ +.git/ +__pycache__/ +*.pyc +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a69624e --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# AHDX has no password in config on purpose. On first run it takes you to a +# setup screen to create one; that password is stored salted in AHDX and also +# becomes the Grafana admin password. Nothing to set here for auth. + +# Optional port overrides +# AHDX_PORT=8088 +# GRAFANA_PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..185e965 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Your health data. Never commit this. +data/ + +# Grafana's own state (dashboards you make live here). Provisioning config under +# grafana/ IS committed; this runtime dir is not. +grafana-data/ + +# Python +__pycache__/ +*.pyc + +# Local env +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d3b4de5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install deps first so this layer caches when only the code changes. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# All state lives here, mounted as a volume from the host. +ENV AHDX_DATA=/data +VOLUME ["/data"] +EXPOSE 8080 + +CMD ["python", "app.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f94c06c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Steffen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..272c558 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# AHDX — Apple Health Data eXporter + +Your Apple Health history, out of Apple's black box and into a plain SQLite +database you own, with a Grafana dashboard on top. It runs in Docker and works +on Windows, Mac, or Linux. + +You're in the EU (or you just think your health data is yours), so here's a way +to actually keep it and look at it. Nothing is sent anywhere. The only time AHDX +touches the network is to load map tiles when you open a workout route. + +## What it looks like + +Steps, resting heart rate, weight, VO₂ max, sleep with a score, and your workout +routes on a real map, per person: + +![Dashboard — Ola](docs/screenshots/dashboard-ola.png) + +![Dashboard — Kari](docs/screenshots/dashboard-kari.png) + +## Get it running + +You need Docker (Docker Desktop on Windows/Mac, or Docker Engine on Linux). + +``` +git clone ahdx +cd ahdx +docker compose up -d --build +``` + +That starts two things: **AHDX** on http://localhost:8088 and **Grafana** on +http://localhost:3000. Grafana waits for AHDX to be healthy before it starts, so +the first boot takes a minute. + +Open http://localhost:8088. On the first visit it asks you to **set a password**. +That password is stored salted in AHDX, and it also becomes the Grafana admin +password (user `admin`), so you set it once and it works for both. + +Your data lives in `./data` next to the compose file. Blow away the containers +whenever you want; the databases stay. + +## Get your data out of the iPhone + +1. Open **Health**, tap your profile picture, choose **Export All Health Data**. +2. You get an `export.zip`. Send it to your computer. +3. In AHDX, go to **Import** and drop the zip in. Big exports take a few minutes; + the page shows a running count. + +Loading a newer export later only adds what's new, so re-exporting every month +doesn't make duplicates. + +Two ways to keep it fresh without clicking, both free: + +- **Watched folder** — drop an export into `data/inbox/` and AHDX imports it on a + timer (every 30 min by default), then moves it to `data/inbox/done/`. +- **Push from the phone** — a free iOS **Shortcut** can POST recent metrics to + `/ingest` on a schedule. See the steps further down. + +Apple has no free server-side API, so a fully automatic *full-history* export +isn't possible — that part stays a manual tap. Automatic *recent* data works fine +through the Shortcut. + +## More than one person + +Each person is their own database. On the **Databases** page you can create, +rename, switch, and delete them, and see each one's size and record counts. +Imports go into whichever database is active. + +In Grafana, the **Person** dropdown up top switches the whole dashboard between +them. + +## The Grafana dashboard + +It ships already set up — the AHDX datasource and the "Apple Health" dashboard +are provisioned from files, so there's nothing to import. Panels: + +- Latest weight, resting HR, VO₂ max, steps, active energy, exercise minutes. +- Steps, heart rate, weight, and energy over time. +- **Sleep**: a nightly score (0–100), stage breakdown (deep / core / REM / awake), + and the score trend. The score is computed from sleep duration, efficiency, and + how much deep/REM you got — Apple doesn't provide one. +- **Workout route** on an OpenStreetMap map, with a box under it showing the + activity, distance, duration, calories, elevation gain, and average speed. + +## The read API + +AHDX exposes a small read API at `/api` (open it in a browser for the menu). It's +what Grafana reads, and you can point your own scripts at it. Pick a person with +`?db=Name`: + +``` +/api/daily?db=Ola&type=HKQuantityTypeIdentifierStepCount&agg=sum +/api/sleep?db=Ola +/api/routes?db=Ola +``` + +Set `AHDX_API_KEY` in the compose file if you want to require an `X-Api-Key` +header on it. + +## Building the iOS Shortcut (the auto-trickle) + +This sends today's step samples; copy the pattern for other metrics. + +1. New Shortcut. Add **Find Health Samples** — Type *Steps*, sorted by End Date, + filtered to "within the last 1 day". +2. Add **Repeat with Each**. Inside it, build a **Dictionary** with keys `type` + (`HKQuantityTypeIdentifierStepCount`), `value` (the item's Value), `unit` + (`count`), `start_date`, `end_date`, `source_name`, then **Add to Variable** + `rows`. +3. After the loop, a **Dictionary** with one key `records` set to `rows`. +4. **Get Contents of URL** — POST to `http://:8088/ingest`, body JSON, + pass that dictionary. +5. In the **Automation** tab, run it on a schedule and turn off "Ask Before + Running". + +## Configuration + +Everything has a sane default; override in a `.env` file or the compose file. + +| Setting | Default | What it does | +|---|---|---| +| `AHDX_PORT` | `8088` | Host port for the app | +| `GRAFANA_PORT` | `3000` | Host port for Grafana | +| `AHDX_AUTH` | `true` | Password-protect the UI | +| `AHDX_SCAN_INTERVAL` | `1800` | Inbox check interval, seconds (0 = off) | +| `AHDX_API_KEY` | *(empty)* | If set, the read API requires this header | + +## What's in the database + +Three tables that mirror the export: `records` (one row per measurement), +`workouts`, and `activity_summary`. Plus `routes`/`route_points` and `ecg` when +you import the full zip. Open any `data/databases/*.db` in a SQLite browser and +it's all right there, no proprietary format. + +## Privacy + +No accounts, no analytics, no outbound calls — except loading map tiles from +OpenStreetMap when you open a route, and only then. Password hashes are salted. +Your health data never leaves the machine. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/app.py b/app.py new file mode 100644 index 0000000..0692d8a --- /dev/null +++ b/app.py @@ -0,0 +1,481 @@ +""" +AHDX web app. + +Small Flask front end over the storage layer: a page to import an Apple Health +export, a dashboard, a browser for the raw records, database management, and a +JSON endpoint the phone can push to. All local, no accounts, no outbound calls. +""" +import base64 +import csv +import io +import os +import secrets +import threading +import urllib.request + +from flask import ( + Flask, Response, abort, flash, jsonify, redirect, render_template, request, + session, url_for, +) +from werkzeug.utils import secure_filename + +import db +import jobs +import scanner + + +def _truthy(v): + return str(v).strip().lower() in ("1", "true", "yes", "on") + + +# Auth ships ON. AHDX keeps its own salted password in the registry DB, set once +# via the GUI on first run. When you set it, AHDX also pushes it to Grafana (see +# _sync_grafana_password) so one password covers both. The /ingest push endpoint +# stays open; it's for the LAN. +AUTH_ENABLED = _truthy(os.environ.get("AHDX_AUTH", "true")) +GRAFANA_URL = os.environ.get("AHDX_GRAFANA_URL", "http://ahdx-grafana:3000").rstrip("/") + + +def _has_password(): + return db.has_password() + + +def _check_password(pw): + return db.check_password(pw) + + +def _sync_grafana_password(new_pw): + """Best-effort: set Grafana's admin password to match. A fresh Grafana ships + as admin/admin, so we change it from there on first setup; on later changes + we try the new password as the "current" one too. Failure is ignored — AHDX + still works, Grafana just keeps whatever it had.""" + import json as _json + for current in ("admin", new_pw): + try: + auth = base64.b64encode(f"admin:{current}".encode()).decode() + body = _json.dumps({"oldPassword": current, "newPassword": new_pw}).encode() + req = urllib.request.Request( + GRAFANA_URL + "/api/user/password", data=body, method="PUT", + headers={"Authorization": "Basic " + auth, "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=5) as r: + if r.status == 200: + return True + except Exception: + continue + return False + +# Optional shared key for the read API. If set, callers (Grafana, scripts) must +# send it as the X-Api-Key header or a ?key= query param. If unset, the API is +# open on the LAN, same as /ingest. +API_KEY = os.environ.get("AHDX_API_KEY") or None + +app = Flask(__name__) + +with app.app_context(): + db.init() + +# A stable session secret. Prefer the env var; otherwise keep a random one in the +# settings table so logins survive a restart without hard-coding anything. +_secret = os.environ.get("SECRET_KEY") or db.get_setting("secret_key") +if not _secret: + _secret = secrets.token_hex(32) + db.set_setting("secret_key", _secret) +app.config["SECRET_KEY"] = _secret + +scanner.start() + +# Endpoints reachable without a login: the login/setup pages themselves, the +# health check, static files, and the machine-to-machine push endpoint. +OPEN_ENDPOINTS = {"login", "setup", "health", "static", "ingest"} + + +@app.before_request +def require_login(): + # The read API is machine-to-machine: it has its own key, so the browser + # login never applies to it. + if request.path.startswith("/api"): + return + if not AUTH_ENABLED or session.get("authed") or request.endpoint in OPEN_ENDPOINTS: + return + # First run with auth on and no password yet: force setting one. + return redirect(url_for("setup" if not _has_password() else "login")) + + +def _api_guard(): + """Return an error response if the API key is required and missing/wrong, + else None.""" + if not API_KEY: + return None + given = request.headers.get("X-Api-Key") or request.args.get("key") + if given != API_KEY: + return jsonify({"error": "unauthorized: send the X-Api-Key header"}), 401 + return None + + +@app.context_processor +def inject_globals(): + # Every page shows the database picker, so hand it the list each render. + return { + "databases": db.list_databases(), + "active_db": db.get_active(), + "auth_enabled": AUTH_ENABLED, + } + + +# ---------- auth (only active when AHDX_AUTH is on) ---------- + +@app.route("/setup", methods=["GET", "POST"]) +def setup(): + if not AUTH_ENABLED or _has_password(): + return redirect(url_for("index")) + if request.method == "POST": + pw = request.form.get("password", "") + confirm = request.form.get("confirm", "") + if len(pw) < 6: + flash("Use at least 6 characters.", "error") + elif pw != confirm: + flash("The two passwords don't match.", "error") + else: + db.set_password(pw) + _sync_grafana_password(pw) # make it the Grafana admin password too + session["authed"] = True + flash("Password set — it's now the login for both AHDX and Grafana.", "success") + return redirect(url_for("index")) + return render_template("auth.html", mode="setup") + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if not AUTH_ENABLED: + return redirect(url_for("index")) + if not _has_password(): + return redirect(url_for("setup")) + if request.method == "POST": + if _check_password(request.form.get("password", "")): + session["authed"] = True + return redirect(url_for("index")) + flash("Wrong password.", "error") + return render_template("auth.html", mode="login") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login") if AUTH_ENABLED else url_for("index")) + + +@app.route("/health") +def health(): + return {"ok": True} + + +# ---------- import ---------- + +@app.route("/") +def index(): + return render_template("import.html", status=db.import_status()) + + +@app.route("/import", methods=["POST"]) +def do_import(): + f = request.files.get("file") + if not f or not f.filename: + flash("Pick your Apple Health export first.", "error") + return redirect(url_for("index")) + if jobs.import_running(): + flash("An import is already running. Give it a moment.", "error") + return redirect(url_for("index")) + + saved = os.path.join(db.UPLOAD_DIR, "upload_" + secure_filename(f.filename)) + f.save(saved) + # Parse off the request thread; the page polls /import/status for progress. + threading.Thread( + target=jobs.run_import, args=(saved, "upload:" + f.filename), daemon=True + ).start() + flash("Import started.", "success") + return redirect(url_for("index")) + + +@app.route("/import/status") +def import_status(): + s = db.import_status() + return jsonify({k: s[k] for k in s.keys()}) + + +# ---------- dashboard + browse ---------- + +@app.route("/dashboard") +def dashboard(): + return render_template("dashboard.html", cards=db.dashboard_cards(), **db.dashboard_stats()) + + +@app.route("/trends") +def trends(): + types = db.record_types() + sel = request.args.get("type") or (types[0] if types else None) + agg = request.args.get("agg", "avg") + if agg not in ("avg", "sum", "min", "max", "count"): + agg = "avg" + points = db.daily_rollup(sel, agg) if sel else [] + stats = db.type_stats(sel) if sel else None + return render_template( + "trends.html", types=types, sel_type=sel or "", agg=agg, + points=points, stats=stats, + ) + + +@app.route("/browse") +def browse(): + type_ = request.args.get("type") or None + start = request.args.get("start") or None + end = request.args.get("end") or None + rows = db.browse(type_, start, end, limit=500) + return render_template( + "browse.html", rows=rows, types=db.record_types(), + sel_type=type_ or "", start=start or "", end=end or "", + ) + + +@app.route("/export.csv") +def export_csv(): + type_ = request.args.get("type") or None + start = request.args.get("start") or None + end = request.args.get("end") or None + rows = db.browse(type_, start, end, limit=5_000_000) + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["type", "source_name", "unit", "value", "start_date", "end_date"]) + yield buf.getvalue() + buf.seek(0); buf.truncate(0) + for r in rows: + writer.writerow([r["type"], r["source_name"], r["unit"], r["value"], + r["start_date"], r["end_date"]]) + yield buf.getvalue() + buf.seek(0); buf.truncate(0) + + return Response( + generate(), mimetype="text/csv", + headers={"Content-Disposition": "attachment; filename=ahdx-records.csv"}, + ) + + +# ---------- routes + ECG ---------- + +@app.route("/routes") +def routes(): + return render_template("routes.html", routes=db.list_routes()) + + +@app.route("/routes/") +def route_detail(route_id): + route, points = db.get_route(route_id) + if not route: + abort(404) + return render_template("route_detail.html", route=route, points=points) + + +@app.route("/ecg") +def ecg(): + return render_template("ecg.html", items=db.list_ecg()) + + +@app.route("/ecg/") +def ecg_detail(ecg_id): + row, samples = db.get_ecg(ecg_id) + if not row: + abort(404) + return render_template("ecg_detail.html", ecg=row, samples=samples) + + +# ---------- databases ---------- + +@app.route("/databases") +def databases(): + return render_template("databases.html", overview=db.database_overview()) + + +@app.route("/databases/new", methods=["POST"]) +def new_database(): + try: + db.create_database(request.form.get("name", "")) + flash("Database created and set active.", "success") + except ValueError as exc: + flash(str(exc), "error") + return redirect(url_for("databases")) + + +@app.route("/databases//rename", methods=["POST"]) +def rename_database(db_id): + try: + db.rename_database(db_id, request.form.get("name", "")) + flash("Database renamed.", "success") + except ValueError as exc: + flash(str(exc), "error") + return redirect(url_for("databases")) + + +@app.route("/databases//activate", methods=["POST"]) +def activate_database(db_id): + db.set_active(db_id) + flash("Switched active database.", "success") + return redirect(request.referrer or url_for("databases")) + + +@app.route("/databases//delete", methods=["POST"]) +def delete_database(db_id): + try: + db.delete_database(db_id) + flash("Database deleted.", "success") + except ValueError as exc: + flash(str(exc), "error") + return redirect(url_for("databases")) + + +# ---------- push endpoint for iOS Shortcuts ---------- + +@app.route("/ingest", methods=["POST"]) +def ingest(): + """Accept a batch of records as JSON and merge them into the active database. + Body is either a list of record objects or {"records": [...]}. A record is + {type, value, unit, start_date, end_date, source_name}. Same dedup as the + XML import, so re-sending an overlapping window is harmless.""" + data = request.get_json(silent=True) + rows = data.get("records") if isinstance(data, dict) else data + if not isinstance(rows, list): + return jsonify({"error": 'send a JSON list, or {"records": [...]}'}), 400 + added = db.ingest_records(rows) + return jsonify({"received": len(rows), "added": added}) + + +# ---------- read API (for Grafana, scripts, etc.) ---------- + +@app.route("/api") +def api_index(): + """The menu. Lists the databases and every endpoint so you can discover the + API by opening this one URL.""" + guard = _api_guard() + if guard: + return guard + base = request.host_url.rstrip("/") + names = [d["name"] for d in db.list_databases()] + example_db = names[0] if names else "Steffen" + return jsonify({ + "app": "AHDX read API", + "databases": names, + "how": "Add ?db= to target a database. Leave it off to use the active one.", + "auth": "open" if not API_KEY else "send X-Api-Key header (or ?key=)", + "endpoints": [ + {"path": "/api/databases", "returns": "the list of databases"}, + {"path": "/api/types", "params": ["db"], "returns": "record types with counts"}, + {"path": "/api/records", "params": ["db", "type", "from", "to", "limit"], + "returns": "raw records"}, + {"path": "/api/daily", "params": ["db", "type", "agg", "from", "to"], + "returns": "one value per day; agg is avg|sum|min|max|count. Best for Grafana."}, + {"path": "/api/workouts", "params": ["db"], "returns": "workouts"}, + {"path": "/api/activity", "params": ["db"], "returns": "daily activity summaries"}, + {"path": "/api/sleep", "params": ["db", "from", "to"], + "returns": "one row per night: hours per stage + a 0-100 score"}, + {"path": "/api/routes", "params": ["db"], "returns": "GPS routes list"}, + {"path": "/api/route", "params": ["db", "id"], "returns": "one route's lat/lon points"}, + ], + "examples": [ + f"{base}/api/types?db={example_db}", + f"{base}/api/daily?db={example_db}&type=HKQuantityTypeIdentifierStepCount&agg=sum", + f"{base}/api/daily?db={example_db}&type=HKQuantityTypeIdentifierHeartRate&agg=avg&from=2026-01-01", + ], + }) + + +@app.route("/api/databases") +def api_databases(): + guard = _api_guard() + if guard: + return guard + return jsonify([ + {"name": d["name"], "file": d["filename"], "active": bool(d["is_active"])} + for d in db.list_databases() + ]) + + +def _with_db(fn): + """Run a db.api_* call, turning an unknown ?db= into a clean 404.""" + guard = _api_guard() + if guard: + return guard + try: + return jsonify(fn()) + except KeyError: + return jsonify({"error": "unknown database; see /api/databases"}), 404 + + +@app.route("/api/types") +def api_types(): + return _with_db(lambda: db.api_types(request.args.get("db"))) + + +@app.route("/api/records") +def api_records(): + type_ = request.args.get("type") + if not type_: + return jsonify({"error": "type is required; see /api/types"}), 400 + limit = min(int(request.args.get("limit", 10000)), 200000) + return _with_db(lambda: db.api_records( + request.args.get("db"), type_, request.args.get("from"), + request.args.get("to"), limit, + )) + + +@app.route("/api/daily") +def api_daily(): + type_ = request.args.get("type") + if not type_: + return jsonify({"error": "type is required; see /api/types"}), 400 + agg = request.args.get("agg", "avg") + if agg not in ("avg", "sum", "min", "max", "count"): + agg = "avg" + return _with_db(lambda: db.api_daily( + request.args.get("db"), type_, agg, + request.args.get("from"), request.args.get("to"), + )) + + +@app.route("/api/workouts") +def api_workouts(): + return _with_db(lambda: db.api_workouts(request.args.get("db"))) + + +@app.route("/api/activity") +def api_activity(): + return _with_db(lambda: db.api_activity(request.args.get("db"))) + + +@app.route("/api/sleep") +def api_sleep(): + return _with_db(lambda: db.api_sleep( + request.args.get("db"), request.args.get("from"), request.args.get("to"))) + + +@app.route("/api/routes") +def api_routes(): + return _with_db(lambda: db.api_routes(request.args.get("db"))) + + +@app.route("/api/route") +def api_route(): + # Missing / empty / unresolved-variable id -> latest route (handled in db). + rid = request.args.get("id") + rid = int(rid) if (rid and rid.isdigit()) else None + return _with_db(lambda: db.api_route_points(request.args.get("db"), rid)) + + +@app.route("/api/route/stats") +def api_route_stats(): + rid = request.args.get("id") + rid = int(rid) if (rid and rid.isdigit()) else None + return _with_db(lambda: db.api_route_stats(request.args.get("db"), rid)) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) diff --git a/db.py b/db.py new file mode 100644 index 0000000..34eb773 --- /dev/null +++ b/db.py @@ -0,0 +1,730 @@ +""" +Storage layer for AHDX. + +Two kinds of database live under the data volume: + + registry.db the list of health databases and which one is active + databases/.db one per health dataset (per person, per year, whatever) + +Keeping them as separate files means you can hand someone a single .db and it's +their whole dataset, nothing else tangled in. Everything here is plain stdlib +sqlite3, no ORM. +""" +import os +import re +import sqlite3 +from contextlib import contextmanager + +DATA_DIR = os.environ.get("AHDX_DATA", "/data") +DB_DIR = os.path.join(DATA_DIR, "databases") +UPLOAD_DIR = os.path.join(DATA_DIR, "uploads") +INBOX_DIR = os.path.join(DATA_DIR, "inbox") +INBOX_DONE = os.path.join(INBOX_DIR, "done") +REGISTRY = os.path.join(DATA_DIR, "registry.db") +SCHEMA = os.path.join(os.path.dirname(__file__), "schema.sql") + + +def _ensure_dirs(): + for d in (DATA_DIR, DB_DIR, UPLOAD_DIR, INBOX_DIR, INBOX_DONE): + os.makedirs(d, exist_ok=True) + + +@contextmanager +def _open(path): + conn = sqlite3.connect(path, timeout=30) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init(): + _ensure_dirs() + with _open(REGISTRY) as c: + c.execute( + """CREATE TABLE IF NOT EXISTS databases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + is_active INTEGER NOT NULL DEFAULT 0 + )""" + ) + # Small app-wide key/value store: the GUI password hash, the session + # secret, anything that isn't health data. + c.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)") + # A fresh install has nothing, so give it one database to land data in. + if not list_databases(): + create_database("My Health") + if get_active() is None: + set_active(list_databases()[0]["id"]) + + # Re-apply the schema to every database on startup. It's all CREATE ... IF + # NOT EXISTS, so this is a no-op on current databases and quietly adds new + # tables (routes, ecg, ...) to ones made by an older version. + for row in list_databases(): + apply_schema(os.path.join(DB_DIR, row["filename"])) + + +# ---------- settings + GUI password ---------- + +def get_setting(key, default=None): + with _open(REGISTRY) as c: + row = c.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else default + + +def set_setting(key, value): + with _open(REGISTRY) as c: + c.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + + +def has_password(): + return get_setting("password_hash") is not None + + +def set_password(pw): + from werkzeug.security import generate_password_hash + # pbkdf2 rather than the newer scrypt default, so it hashes on any Python + # build (scrypt needs an OpenSSL that isn't always compiled in). + set_setting("password_hash", generate_password_hash(pw, method="pbkdf2:sha256")) + + +def check_password(pw): + from werkzeug.security import check_password_hash + h = get_setting("password_hash") + return bool(h and check_password_hash(h, pw)) + + +# ---------- the database registry ---------- + +def list_databases(): + with _open(REGISTRY) as c: + return c.execute("SELECT * FROM databases ORDER BY created_at, id").fetchall() + + +def get_active(): + with _open(REGISTRY) as c: + return c.execute("SELECT * FROM databases WHERE is_active = 1").fetchone() + + +def set_active(db_id): + with _open(REGISTRY) as c: + c.execute("UPDATE databases SET is_active = 0") + c.execute("UPDATE databases SET is_active = 1 WHERE id = ?", (db_id,)) + + +def _slug(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "db" + + +def create_database(name): + name = name.strip() + if not name: + raise ValueError("A database needs a name.") + with _open(REGISTRY) as c: + if c.execute("SELECT 1 FROM databases WHERE name = ?", (name,)).fetchone(): + raise ValueError("A database with that name already exists.") + + # Pick a filename that doesn't collide with one already on disk. + base = _slug(name) + filename = base + ".db" + n = 1 + while os.path.exists(os.path.join(DB_DIR, filename)): + filename = f"{base}-{n}.db" + n += 1 + + apply_schema(os.path.join(DB_DIR, filename)) + with _open(REGISTRY) as c: + cur = c.execute( + "INSERT INTO databases (name, filename) VALUES (?, ?)", (name, filename) + ) + new_id = cur.lastrowid + set_active(new_id) + return new_id + + +def _human_size(n): + size = float(n) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + + +def database_overview(): + """The registry rows plus, for each database, its file size and a few + counts. Opens each .db file, so it's a page-load query, not a hot path.""" + out = [] + for row in list_databases(): + path = os.path.join(DB_DIR, row["filename"]) + info = { + "id": row["id"], "name": row["name"], "filename": row["filename"], + "created_at": row["created_at"], "is_active": row["is_active"], + "size": os.path.getsize(path) if os.path.exists(path) else 0, + "records": 0, "workouts": 0, "last_import": None, + } + info["size_h"] = _human_size(info["size"]) + try: + with _open(path) as c: + info["records"] = c.execute("SELECT COUNT(*) AS n FROM records").fetchone()["n"] + info["workouts"] = c.execute("SELECT COUNT(*) AS n FROM workouts").fetchone()["n"] + st = c.execute("SELECT finished_at FROM import_status WHERE id = 1").fetchone() + info["last_import"] = st["finished_at"] if st else None + except sqlite3.Error: + pass + out.append(info) + return out + + +def rename_database(db_id, new_name): + new_name = new_name.strip() + if not new_name: + raise ValueError("A database needs a name.") + with _open(REGISTRY) as c: + clash = c.execute( + "SELECT 1 FROM databases WHERE name = ? AND id <> ?", (new_name, db_id) + ).fetchone() + if clash: + raise ValueError("A database with that name already exists.") + c.execute("UPDATE databases SET name = ? WHERE id = ?", (new_name, db_id)) + + +def delete_database(db_id): + with _open(REGISTRY) as c: + row = c.execute("SELECT * FROM databases WHERE id = ?", (db_id,)).fetchone() + if not row: + return + if len(list_databases()) <= 1: + raise ValueError("This is the only database, so there's nothing to switch to. Create another first.") + + path = os.path.join(DB_DIR, row["filename"]) + if os.path.exists(path): + os.remove(path) + with _open(REGISTRY) as c: + c.execute("DELETE FROM databases WHERE id = ?", (db_id,)) + if row["is_active"]: + set_active(list_databases()[0]["id"]) + + +def apply_schema(path): + conn = sqlite3.connect(path) + try: + with open(SCHEMA, encoding="utf-8") as f: + conn.executescript(f.read()) + finally: + conn.close() + + +# ---------- the active health database ---------- + +def active_path(): + a = get_active() + return os.path.join(DB_DIR, a["filename"]) + + +@contextmanager +def health(): + with _open(active_path()) as c: + yield c + + +def resolve_db(name): + """Path for a database by name or filename. None (or an unresolved Grafana + variable like "${db}") means the active one; an unknown name raises KeyError + so the API can answer 404.""" + if not name or name.startswith("$"): + return active_path() + with _open(REGISTRY) as c: + row = c.execute( + "SELECT filename FROM databases WHERE lower(name) = lower(?) OR lower(filename) = lower(?)", + (name, name), + ).fetchone() + if not row: + raise KeyError(name) + return os.path.join(DB_DIR, row["filename"]) + + +@contextmanager +def open_db(name=None): + with _open(resolve_db(name)) as c: + yield c + + +# ---------- read API queries (pick the database by name) ---------- + +def api_types(name=None): + with open_db(name) as c: + return [dict(r) for r in c.execute( + "SELECT type, COUNT(*) AS n, MIN(start_date) AS first, MAX(start_date) AS last " + "FROM records GROUP BY type ORDER BY n DESC" + )] + + +def api_records(name, type_, start, end, limit): + q = ("SELECT type, value, value_num, unit, source_name, start_date, end_date " + "FROM records WHERE type = ?") + params = [type_] + if start: + q += " AND start_date >= ?" + params.append(start) + if end: + q += " AND start_date <= ?" + params.append(end) + q += " ORDER BY start_date LIMIT ?" + params.append(limit) + with open_db(name) as c: + return [dict(r) for r in c.execute(q, params)] + + +def api_daily(name, type_, agg, start, end): + """One value per day, shaped as [{"time": "...", "value": n}] so a Grafana + JSON/Infinity datasource can read it straight.""" + expr = _AGG.get(agg, _AGG["avg"]) + where = "type = ?" + params = [type_] + if agg != "count": + where += " AND value_num IS NOT NULL" + if start: + where += " AND start_date >= ?" + params.append(start) + if end: + where += " AND start_date <= ?" + params.append(end) + q = (f"SELECT substr(start_date, 1, 10) AS time, {expr} AS value " + f"FROM records WHERE {where} GROUP BY time ORDER BY time") + with open_db(name) as c: + return [dict(r) for r in c.execute(q, params) if r["time"]] + + +def api_workouts(name=None): + with open_db(name) as c: + rows = [dict(r) for r in c.execute( + "SELECT activity_type, duration, duration_unit, total_distance, distance_unit, " + "total_energy, energy_unit, start_date, end_date, source_name " + "FROM workouts ORDER BY start_date DESC" + )] + for r in rows: + if r["activity_type"]: # "HKWorkoutActivityTypeWalking" -> "Walking" + r["activity_type"] = r["activity_type"].replace("HKWorkoutActivityType", "") + return rows + + +def api_activity(name=None): + with open_db(name) as c: + return [dict(r) for r in c.execute( + "SELECT date AS time, active_energy, active_energy_goal, move_time, " + "exercise_time, stand_hours FROM activity_summary ORDER BY date" + )] + + +def _parse_dt(s): + from datetime import datetime + try: + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z") # "... +0200" + except (ValueError, TypeError): + return None + + +def api_sleep(name=None, start=None, end=None): + """One row per night with hours per stage and a 0–100 score. Apple has no + native sleep score, so we make one from duration, efficiency, and (when the + watch recorded stages) how much deep/REM sleep there was. Older data only + has "asleep vs in bed", so the score falls back to duration + efficiency.""" + from collections import defaultdict + from datetime import timedelta + q = ("SELECT value, start_date, end_date FROM records " + "WHERE type = 'HKCategoryTypeIdentifierSleepAnalysis'") + params = [] + if start: + q += " AND start_date >= ?"; params.append(start) + if end: + q += " AND start_date <= ?"; params.append(end) + with open_db(name) as c: + rows = c.execute(q, params).fetchall() + + nights = defaultdict(lambda: dict(in_bed=0.0, awake=0.0, deep=0.0, rem=0.0, core=0.0, unspec=0.0)) + for r in rows: + s, e = _parse_dt(r["start_date"]), _parse_dt(r["end_date"]) + if not s or not e: + continue + hours = (e - s).total_seconds() / 3600.0 + if hours <= 0 or hours > 16: + continue + # Assign to a night by shifting 18h back, so an evening + the morning + # after it land on the same date. + night = (s - timedelta(hours=18)).date().isoformat() + v = r["value"] or "" + if v.endswith("InBed"): + nights[night]["in_bed"] += hours + elif v.endswith("Awake"): + nights[night]["awake"] += hours + elif v.endswith("AsleepDeep"): + nights[night]["deep"] += hours + elif v.endswith("AsleepREM"): + nights[night]["rem"] += hours + elif v.endswith("AsleepCore"): + nights[night]["core"] += hours + elif "Asleep" in v: + nights[night]["unspec"] += hours + + out = [] + for night in sorted(nights): + d = nights[night] + asleep = d["deep"] + d["rem"] + d["core"] + d["unspec"] + in_bed = d["in_bed"] if d["in_bed"] > 0 else asleep + d["awake"] + has_stages = (d["deep"] + d["rem"] + d["core"]) > 0 + dur = min(asleep / 8.0, 1.0) # 8h asleep = full marks + eff = min(asleep / in_bed, 1.0) if in_bed else dur + if has_stages: + deep_s = min((d["deep"] / asleep) / 0.16, 1.0) if asleep else 0 + rem_s = min((d["rem"] / asleep) / 0.22, 1.0) if asleep else 0 + score = 100 * (0.40 * dur + 0.25 * eff + 0.175 * deep_s + 0.175 * rem_s) + else: + score = 100 * (0.60 * dur + 0.40 * eff) + out.append({ + "time": night, "asleep_h": round(asleep, 2), "in_bed_h": round(in_bed, 2), + "deep_h": round(d["deep"], 2), "rem_h": round(d["rem"], 2), + "core_h": round(d["core"], 2), "unspecified_h": round(d["unspec"], 2), + "awake_h": round(d["awake"], 2), "efficiency": round(100 * eff), + "score": round(score), + }) + return out + + +def api_routes(name=None): + with open_db(name) as c: + return [dict(r) for r in c.execute( + "SELECT id, filename, start_date, point_count FROM routes ORDER BY start_date DESC" + )] + + +def _haversine_km(a_lat, a_lon, b_lat, b_lon): + import math + r = 6371.0 + dlat, dlon = math.radians(b_lat - a_lat), math.radians(b_lon - a_lon) + h = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(a_lat)) * math.cos(math.radians(b_lat)) * math.sin(dlon / 2) ** 2) + return 2 * r * math.asin(math.sqrt(h)) + + +def api_route_stats(name, route_id=None): + """Distance, duration, elevation gain and average speed for one route, + worked out from its points. No id -> the latest route (matches the map).""" + from datetime import datetime + with open_db(name) as c: + if not route_id: + latest = c.execute("SELECT id FROM routes ORDER BY start_date DESC LIMIT 1").fetchone() + if not latest: + return {} + route_id = latest["id"] + route = c.execute("SELECT filename, start_date FROM routes WHERE id = ?", (route_id,)).fetchone() + pts = c.execute( + "SELECT lat, lon, ele, t FROM route_points WHERE route_id = ? ORDER BY rowid", (route_id,) + ).fetchall() + workouts = c.execute( + "SELECT activity_type, total_energy, start_date FROM workouts").fetchall() + if not route or not pts: + return {} + + dist = 0.0 + gain = 0.0 + prev = prev_ele = None + for p in pts: + if p["lat"] is None or p["lon"] is None: + continue + if prev is not None: + dist += _haversine_km(prev[0], prev[1], p["lat"], p["lon"]) + prev = (p["lat"], p["lon"]) + if p["ele"] is not None: + if prev_ele is not None and p["ele"] > prev_ele: + gain += p["ele"] - prev_ele + prev_ele = p["ele"] + + def _t(s): + for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S.%fZ"): + try: + return datetime.strptime(s, fmt) + except (ValueError, TypeError): + pass + return None + + times = [t for t in (_t(p["t"]) for p in pts) if t] + dur_min = (times[-1] - times[0]).total_seconds() / 60.0 if len(times) >= 2 else None + speed = dist / (dur_min / 60.0) if dur_min else None + + # The GPX has no calories, so match this route to its workout by start time + # (within an hour) and borrow the workout's energy. Route timestamps are + # UTC ("...Z"); workout start dates carry a timezone offset. + from datetime import datetime, timezone + + def _utc(s): + for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S.%fZ"): + try: + return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + pass + try: + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z").astimezone(timezone.utc) + except (ValueError, TypeError): + return None + + route_start = _utc(pts[0]["t"]) or _utc(route["start_date"]) + energy = activity = None + if route_start: + best = None + for w in workouts: + ws = _utc(w["start_date"]) + if not ws: + continue + diff = abs((ws - route_start).total_seconds()) + if diff < 3600 and (best is None or diff < best[0]): + best = (diff, w) + if best: + energy = best[1]["total_energy"] + activity = (best[1]["activity_type"] or "").replace("HKWorkoutActivityType", "") + + return { + "filename": route["filename"], "start_date": (route["start_date"] or "")[:10], + "activity": activity, "distance_km": round(dist, 2), "elevation_gain_m": round(gain), + "duration_min": round(dur_min, 1) if dur_min else None, + "avg_speed_kmh": round(speed, 1) if speed else None, + "energy_kcal": round(energy) if energy else None, "points": len(pts), + } + + +def api_route_points(name, route_id=None, max_points=800): + """A route's lat/lon, thinned so a map isn't handed 6,000 points. With no + route_id, use the most recent route, so a map always has something to draw + even before anyone picks one.""" + with open_db(name) as c: + if not route_id: + latest = c.execute( + "SELECT id FROM routes ORDER BY start_date DESC LIMIT 1" + ).fetchone() + if not latest: + return [] + route_id = latest["id"] + total = c.execute( + "SELECT COUNT(*) AS n FROM route_points WHERE route_id = ?", (route_id,) + ).fetchone()["n"] + step = max(1, total // max_points) + rows = c.execute( + "SELECT lat, lon FROM route_points WHERE route_id = ? AND rowid % ? = 0 ORDER BY rowid", + (route_id, step), + ).fetchall() + return [{"lat": r["lat"], "lon": r["lon"]} for r in rows if r["lat"] is not None] + + +def import_status(): + with health() as c: + return c.execute("SELECT * FROM import_status WHERE id = 1").fetchone() + + +def dashboard_stats(): + with health() as c: + totals = c.execute( + "SELECT COUNT(*) AS n, MIN(start_date) AS first, MAX(start_date) AS last FROM records" + ).fetchone() + types = c.execute( + "SELECT type, COUNT(*) AS n, MIN(start_date) AS first, MAX(start_date) AS last " + "FROM records GROUP BY type ORDER BY n DESC" + ).fetchall() + workouts = c.execute("SELECT COUNT(*) AS n FROM workouts").fetchone()["n"] + summaries = c.execute("SELECT COUNT(*) AS n FROM activity_summary").fetchone()["n"] + status = c.execute("SELECT * FROM import_status WHERE id = 1").fetchone() + return { + "totals": totals, "types": types, "workouts": workouts, + "summaries": summaries, "status": status, + } + + +def record_types(): + with health() as c: + return [r["type"] for r in c.execute("SELECT DISTINCT type FROM records ORDER BY type")] + + +def browse(type_=None, start=None, end=None, limit=500): + q = "SELECT type, source_name, unit, value, start_date, end_date FROM records WHERE 1 = 1" + params = [] + if type_: + q += " AND type = ?" + params.append(type_) + if start: + q += " AND start_date >= ?" + params.append(start) + if end: + q += " AND start_date <= ?" + params.append(end) + q += " ORDER BY start_date DESC LIMIT ?" + params.append(limit) + with health() as c: + return c.execute(q, params).fetchall() + + +def _fmt(num, raw): + if num is None: + return raw or "" + if abs(num - round(num)) < 1e-9: + return str(int(round(num))) + return f"{num:.1f}" + + +# The day lives in the first 10 chars of start_date ("2026-07-20 09:00 +0200"). +# SQLite's date() chokes on the trailing timezone, so we slice instead. +_AGG = { + "avg": "AVG(value_num)", "sum": "SUM(value_num)", + "min": "MIN(value_num)", "max": "MAX(value_num)", "count": "COUNT(*)", +} + + +def daily_rollup(type_, agg="avg", start=None, end=None): + """One value per day for a record type. agg picks how the day's samples are + combined: avg/min/max for things like heart rate, sum for step count and + energy, count for how many samples landed that day.""" + expr = _AGG.get(agg, _AGG["avg"]) + where = "type = ?" + params = [type_] + if agg != "count": + where += " AND value_num IS NOT NULL" + if start: + where += " AND start_date >= ?" + params.append(start) + if end: + where += " AND start_date <= ?" + params.append(end) + q = (f"SELECT substr(start_date, 1, 10) AS day, {expr} AS v " + f"FROM records WHERE {where} GROUP BY day ORDER BY day") + with health() as c: + return [(r["day"], r["v"]) for r in c.execute(q, params) if r["day"]] + + +def type_stats(type_): + with health() as c: + return c.execute( + "SELECT COUNT(*) AS n, AVG(value_num) AS avg, MIN(value_num) AS min, " + "MAX(value_num) AS max, SUM(value_num) AS sum, MAX(unit) AS unit, " + "MIN(start_date) AS first, MAX(start_date) AS last " + "FROM records WHERE type = ?", + (type_,), + ).fetchone() + + +# The handful of metrics worth showing at a glance, if the database has them. +_CARD_LATEST = [ + ("HKQuantityTypeIdentifierBodyMass", "Weight"), + ("HKQuantityTypeIdentifierRestingHeartRate", "Resting HR"), + ("HKQuantityTypeIdentifierHeartRate", "Heart rate"), + ("HKQuantityTypeIdentifierBodyMassIndex", "BMI"), + ("HKQuantityTypeIdentifierVO2Max", "VO2 max"), +] + + +def dashboard_cards(): + cards = [] + with health() as c: + # Steps summed over the most recent day that has any. + row = c.execute( + "SELECT substr(start_date, 1, 10) AS day, SUM(value_num) AS s " + "FROM records WHERE type = 'HKQuantityTypeIdentifierStepCount' " + "AND value_num IS NOT NULL GROUP BY day ORDER BY day DESC LIMIT 1" + ).fetchone() + if row and row["day"]: + cards.append({"label": "Steps", "value": str(int(row["s"])), + "unit": "", "when": row["day"]}) + for type_, label in _CARD_LATEST: + r = c.execute( + "SELECT value, value_num, unit, start_date FROM records " + "WHERE type = ? ORDER BY start_date DESC LIMIT 1", (type_,) + ).fetchone() + if r: + cards.append({"label": label, "value": _fmt(r["value_num"], r["value"]), + "unit": r["unit"] or "", "when": (r["start_date"] or "")[:10]}) + return cards + + +# ---------- routes + ECG ---------- + +def list_routes(): + with health() as c: + return c.execute( + "SELECT id, filename, start_date, point_count FROM routes ORDER BY start_date DESC" + ).fetchall() + + +def get_route(route_id): + with health() as c: + route = c.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone() + pts = c.execute( + "SELECT lat, lon, ele FROM route_points WHERE route_id = ? ORDER BY rowid", + (route_id,), + ).fetchall() + return route, [(p["lat"], p["lon"]) for p in pts if p["lat"] is not None] + + +def list_ecg(): + with health() as c: + return c.execute( + "SELECT id, filename, recorded_date, classification, sample_rate, duration_s " + "FROM ecg ORDER BY recorded_date DESC" + ).fetchall() + + +def get_ecg(ecg_id, max_points=1200): + """Return the ECG row plus its waveform, thinned to at most max_points so the + browser isn't asked to draw 15,000 dots.""" + with health() as c: + row = c.execute("SELECT * FROM ecg WHERE id = ?", (ecg_id,)).fetchone() + total = c.execute( + "SELECT COUNT(*) AS n FROM ecg_samples WHERE ecg_id = ?", (ecg_id,) + ).fetchone()["n"] + step = max(1, total // max_points) + samples = [ + r["uv"] for r in c.execute( + "SELECT idx, uv FROM ecg_samples WHERE ecg_id = ? AND idx % ? = 0 ORDER BY idx", + (ecg_id, step), + ) + ] + return row, samples + + +def counts_extras(): + with health() as c: + r = c.execute("SELECT COUNT(*) AS n FROM routes").fetchone()["n"] + e = c.execute("SELECT COUNT(*) AS n FROM ecg").fetchone()["n"] + return {"routes": r, "ecg": e} + + +def ingest_records(rows): + """Merge a list of record dicts into the active database. Used by the push + endpoint. Returns how many were actually new. Same INSERT OR IGNORE dedup as + the XML import, so the phone can re-send overlapping windows safely.""" + cols = ("type", "source_name", "source_version", "device", "unit", + "value", "value_num", "start_date", "end_date", "creation_date") + tuples = [] + for r in rows: + value = r.get("value") + try: + value_num = float(value) + except (TypeError, ValueError): + value_num = None + tuples.append(( + r.get("type"), r.get("source_name"), r.get("source_version"), + r.get("device"), r.get("unit"), None if value is None else str(value), + value_num, r.get("start_date"), r.get("end_date"), r.get("creation_date"), + )) + with health() as c: + before = c.total_changes + c.executemany( + f"INSERT OR IGNORE INTO records ({','.join(cols)}) " + f"VALUES ({','.join('?' * len(cols))})", + tuples, + ) + return c.total_changes - before diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..913ea97 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +services: + ahdx: + build: . + container_name: ahdx + ports: + # Host port is configurable; set AHDX_PORT to move it. Default 8088. + - "${AHDX_PORT:-8088}:8080" + volumes: + # Your data stays in ./data on the host. Delete the container any time; + # the databases and inbox are untouched. + - ./data:/data + environment: + # How often the inbox folder is checked, in seconds. Set to 0 to turn the + # watcher off and only import by hand. + - AHDX_SCAN_INTERVAL=1800 + # Password-protect the web UI. On first run AHDX has no password in its DB, + # so it takes you to a setup screen to create one (stored salted). That + # same password is then pushed to Grafana, so one login covers both. + - AHDX_AUTH=true + # Optional shared key for the read API at /api (used by Grafana, scripts). + # Leave blank to keep the API open on your LAN; set a value to require it + # as the X-Api-Key header. + - AHDX_API_KEY= + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] + interval: 10s + timeout: 5s + retries: 6 + restart: unless-stopped + + grafana: + image: grafana/grafana-oss:latest + container_name: ahdx-grafana + ports: + - "${GRAFANA_PORT:-3000}:3000" + environment: + # The Infinity datasource reads AHDX's JSON API. Installed on first boot. + - GF_INSTALL_PLUGINS=yesoreyeram-infinity-datasource + - GF_SECURITY_ADMIN_USER=admin + # Grafana keeps its default admin/admin at first boot; AHDX changes it to + # the password you set on the AHDX setup screen. Don't pin it here or AHDX + # can't bootstrap it. + volumes: + - ./grafana-data:/var/lib/grafana + # Auto-adds the "AHDX" datasource + the Apple Health dashboard. + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/etc/dashboards + depends_on: + ahdx: + condition: service_healthy # Grafana waits until AHDX is up + restart: unless-stopped diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..08064c6 --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,173 @@ +# AHDX build plan + +AHDX is a small self-hosted app for pulling your own Apple Health data out of +Apple's export and into a SQLite database you control. It runs in Docker, works +on Windows through Docker Desktop, and has a web page for loading the export and +looking through it. Everything stays inside the container. Nothing is sent +anywhere. + +AHDX is short for Apple Health Data eXporter. + +## Why it exists + +You're in the EU, so the health data Apple holds about you is yours to take and +keep. Apple will export it, but what you get back is one enormous XML file +that's awkward to use. This app turns that file into an ordinary SQLite +database and gives you a browser page to import and query it. No account, no +cloud, no telemetry. + +## What Apple actually gives you + +On the iPhone: Health app, tap your profile picture, "Export All Health Data". +You get `export.zip`. Inside it: + +- `apple_health_export/export.xml` is the main file and the only one we need to + start. It holds `` entries (heart rate, steps, weight, sleep, etc.), + `` entries, and `` rows (one per day). This file + gets big: real exports run from tens of megabytes to a few gigabytes. +- `workout-routes/*.gpx` are GPS tracks for outdoor workouts. +- `electrocardiograms/*.csv` are ECG readings. +- `export_cda.xml` is a clinical-format copy of the same data. We skip it. + +The size is the one real problem to solve. We can't load the whole XML into +memory, so we stream it. + +## Stack + +Same shape as the other self-hosted apps here, because it works and there's +little to break: + +- Python + Flask, server-rendered pages, one CSS file. No frontend framework. +- SQLite via the stdlib `sqlite3`. No ORM. +- Docker + docker-compose. One service, one volume for the databases and uploads. +- XML parsing with `xml.etree.ElementTree.iterparse`. It reads the file element + by element and lets us throw each one away after we've stored it, so memory + stays flat no matter how large the export is. + +If we add charts, they get drawn in the browser from data the page already has, +with the chart library shipped inside the image. No CDN, so it still works with +the machine offline. + +## Data model, first cut + +Three tables cover nearly everything in export.xml: + +- `records`: one row per measurement. Columns: type, source_name, source_version, + device, unit, value, value_num, start_date, end_date, creation_date. Index on + (type, start_date). +- `workouts`: activity_type, duration, duration_unit, total_distance, + distance_unit, total_energy, energy_unit, start_date, end_date, source_name. +- `activity_summary`: date, active_energy_burned, active_energy_goal, move_time, + exercise_time, stand_hours. + +`value` keeps the raw text from the file. `value_num` holds the same thing as a +REAL when it parses as a number, so numeric types (weight, heart rate) chart +cleanly while text types (like sleep state) still survive the round trip. + +Later, if you want them: workout GPS points, ECG samples, and the metadata +key/value pairs Apple attaches to some records. + +## The "add a database" part + +You said standard SQLite, with a way to add databases. Here's what I'd build. +Tell me if you meant something different. + +The volume has a `databases/` folder. Each health database is one `.db` file in +there. A small `registry.db` tracks the list and which one is active. The UI +gives you: + +- a picker to switch the active database, +- "New database": name it, the app creates an empty `.db` and switches to it, +- import always writes into whichever database is active. + +That handles the cases I'd expect: one database per person in the house, a fresh +one each year, or a scratch database to test an import before you trust it. + +## Pages + +- `/` import. Drop `export.zip` or `export.xml`. It parses into the active + database and shows a live count while it runs. +- `/dashboard` what's in here: record count per type, the date range covered, + number of workouts, last import time. +- `/browse` pick a type and a date range, read the rows, download them as CSV. +- `/databases` list, switch, create, delete. +- a status endpoint for Docker's health check. + +## Import, the tricky bit + +A big export takes a while to parse, so import can't block a request until it +finishes. I'd run the parse in a background thread and write progress (rows +seen, current record type) to a status row that the import page polls. That's +enough for a single-user local app; a real job queue would be overkill. + +Inserts go in batches, committing every few thousand rows, and each XML element +gets cleared right after we read it. + +Re-importing is a merge, not a reload. Every export holds your whole history, so +loading a newer one shouldn't duplicate the old rows. Apple gives records no +stable ID, so we make our own key: a UNIQUE index on +(type, start_date, end_date, value, source_name) with `INSERT OR IGNORE`. Load +the same export twice and nothing changes; load a fresher one and only the new +rows land. Workouts key on (activity_type, start_date, end_date, source_name), +activity summaries on the date. This is what keeps "the latest" flowing into the +database without a wipe. + +## Keeping it current (the interval scan) + +You asked for a periodic scan that pulls recent data, free, with nothing behind +a paid tier. The limit to be honest about: Apple has no free server-side API. A +container on your PC can't reach into the phone and pull Health data by itself. +The full `export.xml` is a manual export from the Health app. So the "interval +scan" watches for exports rather than fetching them, and there's a push path for +the automatable part. + +Two free ways to feed it, both landing in the same merge: + +1. Watched inbox. AHDX checks `data/inbox/` on a timer (default every 30 + minutes, set by `AHDX_SCAN_INTERVAL`). Drop an `export.zip` or `export.xml` + in there, or point a synced folder at it, and the container ingests it on its + own and moves the file to `data/inbox/done/`. No clicking. +2. Push endpoint for iOS Shortcuts. A free Shortcut automation on the phone can + read recent metrics (steps, heart rate, weight, sleep) on a schedule and POST + them as JSON to `/ingest`. Built into iOS, no third-party app, no paid tier. + It won't carry full history the way the manual export does, but it keeps + recent data arriving hands-free. + +What we can't do for free: a fully automatic full-history export. Apple only +does that by hand. Automatic recent data is fine through the Shortcut push. The +README will spell out both, with the exact Shortcut steps. + +## Build order + +1. Skeleton. Dockerfile, compose, a Flask app that boots, empty pages, status + endpoint. Confirm it runs on Windows via Docker Desktop and the page loads at + http://localhost:PORT. +2. Parser and import. Streaming XML into the three tables, background parse with + progress. This is the core. Get it solid against a real export. +3. Browse and dashboard. Counts, filtering, CSV export. +4. Multiple databases. Registry, switch, create, delete. +5. Release polish. A README with the export steps and screenshots, a license, a + small sample export.xml so people can try it, and a plain statement that the + app makes no network calls. + +## Open-source prep (later) + +No git repo yet, per your note. When we set one up: + +- License: MIT if you want the widest reuse, AGPL if you want anyone who runs a + modified hosted copy to publish their changes. Your call. +- README that leads with "your data, your machine" and the export steps. +- No analytics and no outbound requests, stated plainly, since this is health data. + +## What I need from you + +1. Does AHDX stand for Apple Health Data eXport, or something else? +2. Import scope to start: just `export.xml`, or accept the whole `export.zip` and + pull the XML out ourselves? The zip is friendlier for you. +3. "Add a database": is the registry idea above what you meant, or did you mean + attaching an existing external `.db` file, or room for other engines (Postgres) + down the line? +4. A port preference. I'd default to 8080. +5. If you can drop a real `export.xml` (or a trimmed chunk of one) into the + project, I'll build the parser against actual data. Apple's XML has quirks + that only show up in a real file. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..cdeae0c --- /dev/null +++ b/docs/screenshots/README.md @@ -0,0 +1,9 @@ +# Screenshots + +Two images belong here, referenced from the top-level README: + +- `dashboard-ola.png` — the Apple Health dashboard with Person = Ola +- `dashboard-kari.png` — the same dashboard with Person = Kari + +Grab them from Grafana (http://localhost:3000, the "Apple Health" dashboard), +switch the Person dropdown, and save a screenshot of each with these names. diff --git a/grafana/dashboards/apple-health.json b/grafana/dashboards/apple-health.json new file mode 100644 index 0000000..e6d1734 --- /dev/null +++ b/grafana/dashboards/apple-health.json @@ -0,0 +1,1589 @@ +{ + "editable": true, + "id": null, + "panels": [ + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Steps", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierStepCount&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Steps", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Resting HR", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierRestingHeartRate&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Resting HR", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "masskg" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 8, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Weight", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierBodyMass&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Weight", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#4ECDC4", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 12, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "VO₂ max", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierVO2Max&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "VO₂ max", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 0 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Active energy", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierActiveEnergyBurned&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Active energy", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 20, + "y": 0 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Exercise min", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierAppleExerciseTime&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Exercise min", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "fixed" + }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 10, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Steps per day", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierStepCount&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Steps per day", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "purple", + "mode": "fixed" + }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 11, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Active energy per day", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierActiveEnergyBurned&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Active energy per day", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 13 + }, + "id": 12, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Resting heart rate", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierRestingHeartRate&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Resting heart rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "fixed" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "always", + "spanNulls": true + }, + "unit": "masskg" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 13 + }, + "id": 13, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Weight", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierBodyMass&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Weight", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "orange", + "mode": "fixed" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 21 + }, + "id": 14, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Heart rate (daily avg)", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierHeartRate&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Heart rate (daily avg)", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "fixed" + }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 21 + }, + "id": 15, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Exercise minutes per day", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierAppleExerciseTime&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Exercise minutes per day", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 59 + }, + "id": 20, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Start" + } + ] + }, + "targets": [ + { + "columns": [ + { + "selector": "start_date", + "text": "Start", + "type": "string" + }, + { + "selector": "activity_type", + "text": "Activity", + "type": "string" + }, + { + "selector": "total_distance", + "text": "Distance", + "type": "number" + }, + { + "selector": "total_energy", + "text": "Energy (kcal)", + "type": "number" + }, + { + "selector": "duration", + "text": "Duration (min)", + "type": "number" + }, + { + "selector": "source_name", + "text": "Source", + "type": "string" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "table", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/workouts?db=${db}", + "url_options": { + "method": "GET" + } + } + ], + "title": "Recent workouts", + "type": "table" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 60 + }, + { + "color": "green", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 29 + }, + "id": 30, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "score", + "text": "Sleep score", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/sleep?db=${db}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Sleep score (last night)", + "type": "gauge" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 0, + "stacking": { + "group": "A", + "mode": "normal" + } + }, + "unit": "h" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Deep" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Core" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "REM" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#4ECDC4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Awake" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 18, + "x": 6, + "y": 29 + }, + "id": 31, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "deep_h", + "text": "Deep", + "type": "number" + }, + { + "selector": "core_h", + "text": "Core", + "type": "number" + }, + { + "selector": "rem_h", + "text": "REM", + "type": "number" + }, + { + "selector": "awake_h", + "text": "Awake", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "table", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/sleep?db=${db}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Sleep stages per night (hours)", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "purple", + "mode": "fixed" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineInterpolation": "smooth", + "lineWidth": 2, + "showPoints": "never" + }, + "max": 100, + "min": 0, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 36 + }, + "id": 32, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "score", + "text": "Sleep score", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/sleep?db=${db}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Sleep score over time", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 33, + "options": { + "basemap": { + "config": { + "showLabels": true, + "theme": "auto" + }, + "name": "Basemap", + "tooltip": false, + "type": "carto" + }, + "controls": { + "mouseWheelZoom": true, + "showAttribution": true, + "showDebug": false, + "showMeasure": false, + "showScale": false, + "showZoom": true + }, + "layers": [ + { + "config": { + "showLegend": false, + "style": { + "color": { + "fixed": "blue" + }, + "opacity": 0.6, + "size": { + "fixed": 3, + "max": 8, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/circle.svg", + "mode": "fixed" + }, + "textConfig": { + "fontSize": 12, + "textAlign": "center", + "textBaseline": "middle" + } + } + }, + "filterData": { + "id": "byRefId", + "options": "A" + }, + "location": { + "latitude": "lat", + "longitude": "lon", + "mode": "coords" + }, + "name": "Layer 1", + "tooltip": false, + "type": "markers" + } + ], + "tooltip": { + "mode": "none" + }, + "view": { + "allLayers": true, + "id": "fit", + "lastOnly": false, + "lat": 60.9, + "layer": "Layer 1", + "lon": 10.9, + "padding": 10, + "zoom": 12 + } + }, + "targets": [ + { + "columns": [ + { + "selector": "lat", + "text": "lat", + "type": "number" + }, + { + "selector": "lon", + "text": "lon", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "table", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/route?db=${db}&id=${route}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Workout route (choose it in the Route dropdown up top)", + "type": "geomap" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 54 + }, + "id": 34, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "activity", + "text": "Activity", + "type": "string" + }, + { + "selector": "distance_km", + "text": "Distance (km)", + "type": "number" + }, + { + "selector": "duration_min", + "text": "Duration (min)", + "type": "number" + }, + { + "selector": "energy_kcal", + "text": "Calories (kcal)", + "type": "number" + }, + { + "selector": "elevation_gain_m", + "text": "Elevation ↑ (m)", + "type": "number" + }, + { + "selector": "avg_speed_kmh", + "text": "Speed (km/h)", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "table", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/route/stats?db=${db}&id=${route}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Selected route", + "type": "stat" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [ + "ahdx" + ], + "templating": { + "list": [ + { + "current": {}, + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "definition": "AHDX databases", + "hide": 0, + "includeAll": false, + "label": "Person", + "multi": false, + "name": "db", + "options": [], + "query": { + "infinityQuery": { + "columns": [ + { + "selector": "name", + "text": "", + "type": "string" + } + ], + "filters": [], + "format": "table", + "parser": "backend", + "refId": "variable", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/databases", + "url_options": { + "data": "", + "method": "GET" + } + }, + "query": "", + "queryType": "infinity" + }, + "refresh": 1, + "regex": "", + "sort": 1, + "type": "query" + }, + { + "current": {}, + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "definition": "AHDX routes", + "hide": 0, + "includeAll": false, + "label": "Route", + "multi": false, + "name": "route", + "options": [], + "query": { + "infinityQuery": { + "columns": [ + { + "selector": "filename", + "text": "__text", + "type": "string" + }, + { + "selector": "id", + "text": "__value", + "type": "string" + } + ], + "filters": [], + "format": "table", + "parser": "backend", + "refId": "variable", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/routes?db=${db}", + "url_options": { + "data": "", + "method": "GET" + } + }, + "query": "", + "queryType": "infinity" + }, + "refresh": 2, + "regex": "", + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-1y", + "to": "now" + }, + "timezone": "", + "title": "Apple Health", + "uid": "ahdx-health", + "version": 1 +} \ No newline at end of file diff --git a/grafana/provisioning/dashboards/dashboards.yml b/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..e5e15d5 --- /dev/null +++ b/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +# Loads the bundled dashboards from /etc/dashboards (mounted from +# ./grafana/dashboards). allowUiUpdates lets you keep editing them in the UI. +apiVersion: 1 + +providers: + - name: AHDX + type: file + disableDeletion: false + allowUiUpdates: true + updateIntervalSeconds: 30 + options: + path: /etc/dashboards + foldersFromFilesStructure: false diff --git a/grafana/provisioning/datasources/ahdx.yml b/grafana/provisioning/datasources/ahdx.yml new file mode 100644 index 0000000..0fed75c --- /dev/null +++ b/grafana/provisioning/datasources/ahdx.yml @@ -0,0 +1,17 @@ +# Auto-adds the Infinity datasource, pointed at the AHDX container over the +# internal Docker network (so no host IP needed). Panels query paths like +# /api/daily?db=Steffen&type=...&agg=sum against this base URL. +apiVersion: 1 + +datasources: + - name: AHDX + uid: ahdx-infinity # fixed, so the shipped dashboard can reference it + type: yesoreyeram-infinity-datasource + access: proxy + url: http://ahdx:8080 + isDefault: true + jsonData: + # If you set AHDX_API_KEY, add it as a header here: + # httpHeaderName1: X-Api-Key + # and put the value under secureJsonData.httpHeaderValue1 + tlsSkipVerify: true diff --git a/jobs.py b/jobs.py new file mode 100644 index 0000000..b747b19 --- /dev/null +++ b/jobs.py @@ -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() diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..8887fd9 --- /dev/null +++ b/parser.py @@ -0,0 +1,273 @@ +""" +Streaming parser for Apple's Health export.xml. + +The file is one root holding a flat list of , +and children. Real exports run to gigabytes, so we never build +the whole tree. iterparse hands us each element as it closes; we read its +attributes, queue an insert, then clear the root so parsed elements don't pile +up. Memory stays flat whether the file is 20 MB or 5 GB. + +Import is a merge: INSERT OR IGNORE against the uniqueness indexes in schema.sql. +Loading the same export twice changes nothing; a fresher export only adds rows +that weren't there. +""" +import re +import sqlite3 +import xml.etree.ElementTree as ET +import zipfile +from datetime import datetime, timezone + +BATCH = 5000 # rows per executemany; bigger isn't faster once the disk is the limit + +RECORD_COLS = ("type", "source_name", "source_version", "device", "unit", + "value", "value_num", "start_date", "end_date", "creation_date") + + +def _num(s): + try: + return float(s) + except (TypeError, ValueError): + return None + + +def _now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + +def parse(xml_path, db_path, source="upload"): + """Merge one export.xml into the database at db_path. Runs in its own thread, + so it opens its own connection (sqlite connections can't cross threads).""" + conn = sqlite3.connect(db_path, timeout=60) + try: + _run(conn, xml_path, source) + except Exception as exc: + conn.execute( + "UPDATE import_status SET state = 'error', message = ?, finished_at = ? WHERE id = 1", + (str(exc)[:1000], _now()), + ) + conn.commit() + raise + finally: + conn.close() + + +def _run(conn, xml_path, source): + conn.execute( + "UPDATE import_status SET state = 'running', records_seen = 0, records_new = 0, " + "current_type = NULL, source = ?, message = NULL, started_at = ?, finished_at = NULL " + "WHERE id = 1", + (source, _now()), + ) + conn.commit() + + insert_record = ( + f"INSERT OR IGNORE INTO records ({','.join(RECORD_COLS)}) " + f"VALUES ({','.join('?' * len(RECORD_COLS))})" + ) + + batch = [] + seen = 0 + new_before = conn.total_changes + + # events=("start",) once to grab the root, then "end" for everything else. + context = ET.iterparse(xml_path, events=("start", "end")) + _, root = next(context) + + for event, elem in context: + if event != "end": + continue + tag = elem.tag + + if tag == "Record": + a = elem.attrib + batch.append(( + a.get("type"), a.get("sourceName"), a.get("sourceVersion"), + a.get("device"), a.get("unit"), a.get("value"), _num(a.get("value")), + a.get("startDate"), a.get("endDate"), a.get("creationDate"), + )) + seen += 1 + if len(batch) >= BATCH: + conn.executemany(insert_record, batch) + batch.clear() + conn.execute( + "UPDATE import_status SET records_seen = ?, records_new = ?, current_type = ? WHERE id = 1", + (seen, conn.total_changes - new_before, a.get("type")), + ) + conn.commit() + + elif tag == "Workout": + a = elem.attrib + # Older exports carried energy/distance as Workout attributes; newer + # ones moved them into children. Start from the + # attributes (usually empty now) and let the children override. + energy = _num(a.get("totalEnergyBurned")) + energy_unit = a.get("totalEnergyBurnedUnit") + distance = _num(a.get("totalDistance")) + distance_unit = a.get("totalDistanceUnit") + for ch in elem: + if _local(ch.tag) != "WorkoutStatistics": + continue + stat_type = ch.get("type") + total = _num(ch.get("sum")) + if total is None: + continue + if stat_type == "HKQuantityTypeIdentifierActiveEnergyBurned": + energy, energy_unit = total, ch.get("unit") + elif stat_type in ( + "HKQuantityTypeIdentifierDistanceWalkingRunning", + "HKQuantityTypeIdentifierDistanceCycling", + "HKQuantityTypeIdentifierDistanceSwimming", + ): + distance, distance_unit = total, ch.get("unit") + # OR REPLACE (not IGNORE) so re-importing refreshes a workout that + # was stored before this fix and had no energy/distance. + conn.execute( + "INSERT OR REPLACE INTO workouts (activity_type, duration, duration_unit, " + "total_distance, distance_unit, total_energy, energy_unit, start_date, " + "end_date, source_name) VALUES (?,?,?,?,?,?,?,?,?,?)", + (a.get("workoutActivityType"), _num(a.get("duration")), a.get("durationUnit"), + distance, distance_unit, energy, energy_unit, + a.get("startDate"), a.get("endDate"), a.get("sourceName")), + ) + + elif tag == "ActivitySummary": + # Apple has renamed some of these attributes across iOS versions; + # .get() just returns None for the ones a given export doesn't carry. + a = elem.attrib + conn.execute( + "INSERT OR IGNORE INTO activity_summary (date, active_energy, active_energy_goal, " + "move_time, move_time_goal, exercise_time, exercise_time_goal, stand_hours, " + "stand_hours_goal) VALUES (?,?,?,?,?,?,?,?,?)", + (a.get("dateComponents"), _num(a.get("activeEnergyBurned")), + _num(a.get("activeEnergyBurnedGoal")), _num(a.get("appleMoveTime")), + _num(a.get("appleMoveTimeGoal")), _num(a.get("appleExerciseTime")), + _num(a.get("appleExerciseTimeGoal")), _num(a.get("appleStandHours")), + _num(a.get("appleStandHoursGoal"))), + ) + + else: + # A child element closed (MetadataEntry, WorkoutEvent, and friends). + # Skip it, and leave the root alone so we don't drop the parent we're + # still inside. + continue + + # A top-level element is stored. Nothing is half-read at this point, so + # wiping the root frees everything parsed so far. + root.clear() + + if batch: + conn.executemany(insert_record, batch) + + conn.execute( + "UPDATE import_status SET state = 'done', records_seen = ?, records_new = ?, " + "current_type = NULL, finished_at = ? WHERE id = 1", + (seen, conn.total_changes - new_before, _now()), + ) + conn.commit() + + +# ---------- extras: GPS routes + ECG (only present in the zip) ---------- + +def _local(tag): + # Strip the XML namespace: "{http://...}trkpt" -> "trkpt". + return tag.rsplit("}", 1)[-1] + + +def parse_extras(zip_path, db_path): + """Pull the workout-routes/*.gpx and electrocardiograms/*.csv files out of + the export zip. Both dedup on filename, so re-importing skips what's there.""" + if not zipfile.is_zipfile(zip_path): + return + conn = sqlite3.connect(db_path, timeout=60) + try: + with zipfile.ZipFile(zip_path) as z: + for name in z.namelist(): + low = name.lower() + if low.endswith(".gpx"): + _gpx(conn, name.rsplit("/", 1)[-1], z.read(name)) + elif low.endswith(".csv") and "electrocardiogram" in low: + _ecg(conn, name.rsplit("/", 1)[-1], z.read(name).decode("utf-8", "replace")) + conn.commit() + finally: + conn.close() + + +def _gpx(conn, filename, data): + try: + root = ET.fromstring(data) + except ET.ParseError: + return + points = [] + for el in root.iter(): + if _local(el.tag) != "trkpt": + continue + lat, lon = el.get("lat"), el.get("lon") + ele = t = None + for ch in el: + if _local(ch.tag) == "ele": + ele = ch.text + elif _local(ch.tag) == "time": + t = ch.text + points.append((_num(lat), _num(lon), _num(ele), t)) + if not points: + return + cur = conn.execute( + "INSERT OR IGNORE INTO routes (filename, start_date, point_count) VALUES (?, ?, ?)", + (filename, points[0][3], len(points)), + ) + if cur.rowcount == 0: + return # already imported + rid = cur.lastrowid + conn.executemany( + "INSERT INTO route_points (route_id, lat, lon, ele, t) VALUES (?, ?, ?, ?, ?)", + [(rid, p[0], p[1], p[2], p[3]) for p in points], + ) + + +def _ecg(conn, filename, text): + # An Apple ECG csv is a few "key,value" header lines then one voltage sample + # per line. Locale varies (headers and even the decimal comma), so we treat + # any line that parses as a number as a sample and everything else as header. + samples = [] + meta = {} + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + samples.append(float(line.replace(",", "."))) + continue + except ValueError: + pass + if "," in line: + k, v = line.split(",", 1) + meta[k.strip().lower()] = v.strip() + if not samples: + return + + recorded = classification = unit = None + rate = None + for k, v in meta.items(): + if recorded is None and ("recorded" in k or "date" in k or "dato" in k): + recorded = v + if classification is None and ("classif" in k or "klassif" in k): + classification = v + if rate is None and ("sample" in k or "rate" in k or "frekvens" in k): + m = re.search(r"[\d.]+", v) + rate = float(m.group(0)) if m else None + if unit is None and ("unit" in k or "enhet" in k): + unit = v + + cur = conn.execute( + "INSERT OR IGNORE INTO ecg (filename, recorded_date, classification, sample_rate, " + "duration_s, unit) VALUES (?, ?, ?, ?, ?, ?)", + (filename, recorded, classification, rate, + (len(samples) / rate) if rate else None, unit), + ) + if cur.rowcount == 0: + return + eid = cur.lastrowid + conn.executemany( + "INSERT INTO ecg_samples (ecg_id, idx, uv) VALUES (?, ?, ?)", + [(eid, i, s) for i, s in enumerate(samples)], + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c37b670 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask>=3.0 diff --git a/scanner.py b/scanner.py new file mode 100644 index 0000000..72e3f91 --- /dev/null +++ b/scanner.py @@ -0,0 +1,67 @@ +""" +Background watcher for the inbox folder. + +Every AHDX_SCAN_INTERVAL seconds it looks in data/inbox/ for a new export.zip or +export.xml, merges it into the active database, and moves the file to +data/inbox/done/ so it isn't picked up again. Drop exports there by hand or point +a synced folder at it, and imports happen without anyone clicking a button. + +Set AHDX_SCAN_INTERVAL to 0 to turn the watcher off. +""" +import os +import threading +import time + +import db +import jobs + +DEFAULT_INTERVAL = 1800 # 30 minutes + + +def start(): + interval = int(os.environ.get("AHDX_SCAN_INTERVAL", DEFAULT_INTERVAL)) + if interval <= 0: + return + threading.Thread(target=_loop, args=(interval,), daemon=True).start() + + +def _loop(interval): + while True: + try: + scan_once() + except Exception: + # A bad file or a locked database shouldn't kill the watcher; it'll + # try again next tick. + pass + time.sleep(interval) + + +def scan_once(): + """Import every pending file, oldest name first. Returns how many it took.""" + done = 0 + for path in _pending(): + if jobs.run_import(path, source="inbox:" + os.path.basename(path)): + _move_to_done(path) + done += 1 + return done + + +def _pending(): + files = [] + for name in sorted(os.listdir(db.INBOX_DIR)): + p = os.path.join(db.INBOX_DIR, name) + if os.path.isfile(p) and name.lower().endswith((".zip", ".xml")): + files.append(p) + return files + + +def _move_to_done(path): + name = os.path.basename(path) + dest = os.path.join(db.INBOX_DONE, name) + # Don't clobber an earlier file of the same name. + n = 1 + stem, ext = os.path.splitext(name) + while os.path.exists(dest): + dest = os.path.join(db.INBOX_DONE, f"{stem}-{n}{ext}") + n += 1 + os.replace(path, dest) diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..26a5bb7 --- /dev/null +++ b/schema.sql @@ -0,0 +1,116 @@ +-- Schema for one Apple Health database. +-- +-- Apple's export.xml is flat: a big pile of rows plus some +-- and rows. We keep that shape instead of splitting the +-- hundreds of health "types" into their own tables, because Apple adds new +-- types with every iOS release and we don't want a migration each time. +-- +-- Records have no ID from Apple, so we build our own uniqueness key and use +-- INSERT OR IGNORE on import. That way re-scanning the same export is a no-op +-- and a newer export only adds the rows we haven't seen. + +CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, -- e.g. HKQuantityTypeIdentifierHeartRate + source_name TEXT, + source_version TEXT, + device TEXT, + unit TEXT, + value TEXT, -- raw value straight from the file + value_num REAL, -- the same value as a number, or NULL if it isn't one + start_date TEXT, + end_date TEXT, + creation_date TEXT +); + +-- The natural key. COALESCE keeps NULLs from slipping past the uniqueness check +-- (in SQLite two NULLs are treated as distinct, which would let duplicates in). +CREATE UNIQUE INDEX IF NOT EXISTS uq_records ON records ( + type, + COALESCE(start_date, ''), + COALESCE(end_date, ''), + COALESCE(value, ''), + COALESCE(source_name, '') +); +CREATE INDEX IF NOT EXISTS idx_records_type_start ON records (type, start_date); + +CREATE TABLE IF NOT EXISTS workouts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_type TEXT, + duration REAL, + duration_unit TEXT, + total_distance REAL, + distance_unit TEXT, + total_energy REAL, + energy_unit TEXT, + start_date TEXT, + end_date TEXT, + source_name TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_workouts ON workouts ( + COALESCE(activity_type, ''), + COALESCE(start_date, ''), + COALESCE(end_date, ''), + COALESCE(source_name, '') +); + +CREATE TABLE IF NOT EXISTS activity_summary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT UNIQUE, -- one summary per day + active_energy REAL, + active_energy_goal REAL, + move_time REAL, + move_time_goal REAL, + exercise_time REAL, + exercise_time_goal REAL, + stand_hours REAL, + stand_hours_goal REAL +); + +-- GPS tracks from outdoor workouts (the workout-routes/*.gpx files in the zip). +CREATE TABLE IF NOT EXISTS routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT UNIQUE, + start_date TEXT, + point_count INTEGER +); +CREATE TABLE IF NOT EXISTS route_points ( + route_id INTEGER NOT NULL REFERENCES routes(id), + lat REAL, + lon REAL, + ele REAL, + t TEXT +); +CREATE INDEX IF NOT EXISTS idx_route_points ON route_points(route_id); + +-- ECG readings (the electrocardiograms/*.csv files). Each is a short waveform +-- plus some header lines; we keep the metadata we can read and all the samples. +CREATE TABLE IF NOT EXISTS ecg ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT UNIQUE, + recorded_date TEXT, + classification TEXT, + sample_rate REAL, + duration_s REAL, + unit TEXT +); +CREATE TABLE IF NOT EXISTS ecg_samples ( + ecg_id INTEGER NOT NULL REFERENCES ecg(id), + idx INTEGER, + uv REAL +); +CREATE INDEX IF NOT EXISTS idx_ecg_samples ON ecg_samples(ecg_id); + +-- One row, updated while an import runs so the web page can show progress. +CREATE TABLE IF NOT EXISTS import_status ( + id INTEGER PRIMARY KEY CHECK (id = 1), + state TEXT, -- idle | running | done | error + records_seen INTEGER DEFAULT 0, -- rows read from the file + records_new INTEGER DEFAULT 0, -- rows that were actually new + current_type TEXT, + source TEXT, -- what triggered it: upload, inbox filename, or ingest + message TEXT, + started_at TEXT, + finished_at TEXT +); +INSERT OR IGNORE INTO import_status (id, state) VALUES (1, 'idle'); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..030082b --- /dev/null +++ b/static/style.css @@ -0,0 +1,159 @@ +/* One hand-written stylesheet, no framework. Plain and legible, works the same + in Docker with the machine offline. */ + +/* Light by default. Dark follows the OS setting, and the toggle in the header + overrides it either way (stored in localStorage as data-theme on ). */ +:root { + --bg: #f5f5f4; + --panel: #ffffff; + --ink: #1c1c1e; + --muted: #6b7280; + --line: #e2e2e0; + --accent: #0a84ff; /* the Apple Health blue, roughly */ + --danger: #c0392b; + --ok: #2e7d32; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #16171a; + --panel: #1e2024; + --ink: #e8e8ea; + --muted: #9aa0a8; + --line: #2c2f36; + --accent: #4a9eff; + --danger: #ff6b5a; + --ok: #5cba5c; + } +} + +:root[data-theme="dark"] { + --bg: #16171a; + --panel: #1e2024; + --ink: #e8e8ea; + --muted: #9aa0a8; + --line: #2c2f36; + --accent: #4a9eff; + --danger: #ff6b5a; + --ok: #5cba5c; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font: 15px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--ink); + background: var(--bg); +} + +header { + display: flex; + align-items: center; + gap: 24px; + padding: 12px 20px; + background: var(--panel); + border-bottom: 1px solid var(--line); + flex-wrap: wrap; +} + +.brand { font-weight: 700; } +.brand span { display: block; font-weight: 400; font-size: 12px; color: var(--muted); } + +nav { display: flex; gap: 16px; } +nav a { text-decoration: none; color: var(--ink); } +nav a:hover { color: var(--accent); } + +.db-picker { margin-left: auto; color: var(--muted); font-size: 13px; } +.db-picker strong { color: var(--ink); } + +main { max-width: 960px; margin: 24px auto; padding: 0 20px; } + +h1 { font-size: 22px; } +h2 { font-size: 17px; margin-top: 0; } + +.muted { color: var(--muted); } +.err { color: var(--danger); } +code { background: rgba(128,128,128,0.18); padding: 1px 5px; border-radius: 4px; font-size: 13px; } + +.theme-toggle { + background: transparent; + color: var(--ink); + border: 1px solid var(--line); + padding: 4px 9px; + font-size: 15px; +} + +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 16px; + margin: 16px 0; +} + +.flash { padding: 10px 14px; border-radius: 8px; margin: 10px 0; } +.flash.success { background: #e6f4ea; color: var(--ok); } +.flash.error { background: #fdecea; color: var(--danger); } + +button, .btn { + font: inherit; + padding: 7px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--accent); + color: #fff; + cursor: pointer; + text-decoration: none; + display: inline-block; +} +button:hover, .btn:hover { opacity: 0.9; } +button.danger, .btn.danger { background: var(--danger); border-color: var(--danger); } +.btn-sm { padding: 5px 11px; font-size: 13px; border-radius: 7px; } + +/* Subtle button for secondary actions (Rename, Make active). */ +button.secondary { + background: transparent; + color: var(--muted); + border: 1px solid var(--line); +} +button.secondary:hover { color: var(--accent); border-color: var(--accent); opacity: 1; } + +/* One tidy row of actions per table row. */ +.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.actions form { display: inline-flex; gap: 6px; align-items: center; margin: 0; } +.actions input[type=text] { width: 116px; margin: 0; padding: 5px 9px; } + +input[type=file], input[type=text], input[type=date], input[type=password], select { + font: inherit; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--ink); + margin-right: 8px; +} + +.filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } +.filters label { color: var(--muted); } + +.tiles { display: flex; gap: 12px; flex-wrap: wrap; } +.tile { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 16px 20px; + min-width: 120px; +} +.tile .n { display: block; font-size: 26px; font-weight: 700; } +.tile .l { color: var(--muted); font-size: 13px; } + +table { width: 100%; border-collapse: collapse; margin: 12px 0; background: var(--panel); } +th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); } +th { font-size: 13px; color: var(--muted); } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } + +.badge { + background: var(--ok); color: #fff; font-size: 11px; + padding: 2px 7px; border-radius: 20px; +} diff --git a/static/vendor/leaflet/leaflet.css b/static/vendor/leaflet/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/static/vendor/leaflet/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/static/vendor/leaflet/leaflet.js b/static/vendor/leaflet/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/static/vendor/leaflet/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 + + + + + {{ 'Set a password' if mode == 'setup' else 'Log in' }} — AHDX + + + + +
+
AHDXApple Health Data eXporter
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} +
+ {% if mode == 'setup' %} +

Set a password

+

The web UI is protected. Pick a password to use from now on.

+
+

+

+ +
+ {% else %} +

Log in

+
+

+ +
+ {% endif %} +
+
+ + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..87963eb --- /dev/null +++ b/templates/base.html @@ -0,0 +1,54 @@ + + + + + + {% block title %}AHDX{% endblock %} + + + {% block head %}{% endblock %} + + +
+
AHDXApple Health Data eXporter
+ +
+ Database: {{ active_db['name'] if active_db else '—' }} +
+ + {% if auth_enabled %} + Log out + {% endif %} +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + {% block body %}{% endblock %} +
+ + diff --git a/templates/browse.html b/templates/browse.html new file mode 100644 index 0000000..85355e2 --- /dev/null +++ b/templates/browse.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Browse — AHDX{% endblock %} +{% block body %} +

Browse records

+ +
+ + + + + Download CSV +
+ +

Showing up to 500 rows, newest first. The CSV download has no limit.

+ + + + + {% for r in rows %} + + + + + + + + {% else %} + + {% endfor %} + +
TypeValueUnitSourceStart
{{ r['type'].replace('HKQuantityTypeIdentifier','').replace('HKCategoryTypeIdentifier','') }}{{ r['value'] }}{{ r['unit'] or '' }}{{ r['source_name'] or '' }}{{ r['start_date'] }}
No records match. Try a different type or date range.
+{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..e2c2103 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Dashboard — AHDX{% endblock %} +{% block body %} +

Dashboard

+ +{% if cards %} +
+ {% for c in cards %} +
+ {{ c['value'] }}{% if c['unit'] %} {{ c['unit'] }}{% endif %} + {{ c['label'] }}{% if c['when'] %} · {{ c['when'] }}{% endif %} +
+ {% endfor %} +
+{% endif %} + +

What's in here

+
+
{{ totals['n'] or 0 }}records
+
{{ types|length }}types
+
{{ workouts }}workouts
+
{{ summaries }}day summaries
+
+ +

+ {% if totals['first'] %} + Data spans {{ totals['first'][:10] }} to {{ totals['last'][:10] }}. + {% else %} + This database is empty. Import an export on the Import page. + {% endif %} +

+ +{% if types %} +

Record types

+ + + + {% for t in types %} + + + + + + + + {% endfor %} + +
TypeCountFromTo
{{ t['type'].replace('HKQuantityTypeIdentifier','').replace('HKCategoryTypeIdentifier','') }}{{ t['n'] }}{{ t['first'][:10] if t['first'] else '' }}{{ t['last'][:10] if t['last'] else '' }}browse
+{% endif %} +{% endblock %} diff --git a/templates/databases.html b/templates/databases.html new file mode 100644 index 0000000..8bde116 --- /dev/null +++ b/templates/databases.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}Databases — AHDX{% endblock %} +{% block body %} +

Databases

+

+ Each database is its own .db file under the data volume. Keep one + per person, one per year, or a scratch one to test an import. Imports always go + into the active database. +

+ + + + + + + + + + + + + + {% for d in overview %} + + + + + + + + + {% endfor %} + +
NameSizeRecordsWorkoutsLast importActions
+ {{ d['name'] }} + {% if d['is_active'] %}active{% endif %} +
{{ d['filename'] }}
+
{{ d['size_h'] }}{{ '{:,}'.format(d['records']) }}{{ d['workouts'] }}{{ d['last_import'][:16] if d['last_import'] else '—' }} +
+ {% if not d['is_active'] %} +
+ +
+ {% endif %} +
+ + +
+
+ +
+
+
+ +
+

New database

+ + +
+ +
+

Read API

+

Pull data out for Grafana or scripts. Open + /api for the menu. + Pick a database with ?db=Name. Example: + /api/daily?db=Steffen&type=HKQuantityTypeIdentifierStepCount&agg=sum

+
+{% endblock %} diff --git a/templates/ecg.html b/templates/ecg.html new file mode 100644 index 0000000..97dabd4 --- /dev/null +++ b/templates/ecg.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}ECG — AHDX{% endblock %} +{% block body %} +

ECG readings

+

From the electrocardiograms in your export. Click one to see the trace.

+ + + + {% for e in items %} + + + + + + + + {% else %} + + {% endfor %} + +
RecordedClassificationRateLengthFile
{{ (e['recorded_date'] or '')[:19] or 'unknown' }}{{ e['classification'] or '' }}{{ (e['sample_rate']|int) ~ ' Hz' if e['sample_rate'] else '' }}{{ ('%.0f s'|format(e['duration_s'])) if e['duration_s'] else '' }}{{ e['filename'] }}
No ECG readings. They only exist if you've taken ECGs and you import the full export.zip.
+{% endblock %} diff --git a/templates/ecg_detail.html b/templates/ecg_detail.html new file mode 100644 index 0000000..a62de5a --- /dev/null +++ b/templates/ecg_detail.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}ECG — AHDX{% endblock %} +{% block body %} +← ECG +

ECG {{ (ecg['recorded_date'] or '')[:19] }}

+

+ {{ ecg['classification'] or 'no classification' }} + {% if ecg['sample_rate'] %} · {{ ecg['sample_rate']|int }} Hz{% endif %} + {% if ecg['duration_s'] %} · {{ '%.0f'|format(ecg['duration_s']) }} s{% endif %} +

+ +
+ +

Thinned for display. The full waveform is in the database.

+
+ + +{% endblock %} diff --git a/templates/import.html b/templates/import.html new file mode 100644 index 0000000..0f60fa1 --- /dev/null +++ b/templates/import.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}Import — AHDX{% endblock %} +{% block body %} +

Import health data

+

+ On your iPhone: open Health, tap your profile picture, choose "Export All + Health Data". You get a file called export.zip. Drop it here. + You can also drop the export.xml from inside it. +

+ +
+ + +
+ +
+

Last import

+
+ {% include "includes/status.html" %} +
+
+ +
+

Hands-free

+

+ Drop exports into the data/inbox/ folder and AHDX picks them up + on a timer (every 30 minutes by default). Or have a free iOS Shortcut POST + recent metrics to /ingest. Both feed the same database, and + re-sending data you already have does nothing. +

+
+ + +{% endblock %} diff --git a/templates/includes/status.html b/templates/includes/status.html new file mode 100644 index 0000000..5db4405 --- /dev/null +++ b/templates/includes/status.html @@ -0,0 +1,11 @@ +{% if status %} +

State: {{ status['state'] or 'idle' }}

+ {% if status['source'] %}

Source: {{ status['source'] }}

{% endif %} +

Rows read: {{ status['records_seen'] or 0 }} +   new rows added: {{ status['records_new'] or 0 }}

+ {% if status['current_type'] %}

Now reading: {{ status['current_type'] }}

{% endif %} + {% if status['message'] %}

{{ status['message'] }}

{% endif %} + {% if status['finished_at'] %}

Finished {{ status['finished_at'] }} UTC

{% endif %} +{% else %} +

Nothing imported yet.

+{% endif %} diff --git a/templates/route_detail.html b/templates/route_detail.html new file mode 100644 index 0000000..7e7f6c3 --- /dev/null +++ b/templates/route_detail.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Route — AHDX{% endblock %} +{% block head %} + +{% endblock %} +{% block body %} +← Routes +

{{ route['filename'] }}

+

{{ (route['start_date'] or '')[:19] }} · {{ route['point_count'] }} points

+ +
+
+
+

Track is from your data. Map tiles come from OpenStreetMap over the network.

+ + + +{% endblock %} diff --git a/templates/routes.html b/templates/routes.html new file mode 100644 index 0000000..7d7fa45 --- /dev/null +++ b/templates/routes.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Routes — AHDX{% endblock %} +{% block body %} +

GPS routes

+

From the workout-routes files in your export. Click one to see its shape.

+ + + + {% for r in routes %} + + + + + + {% else %} + + {% endfor %} + +
DateFilePoints
{{ (r['start_date'] or '')[:10] }}{{ r['filename'] }}{{ r['point_count'] }}
No routes yet. Import an export.zip (not just the xml) and they'll show up.
+{% endblock %} diff --git a/templates/trends.html b/templates/trends.html new file mode 100644 index 0000000..86bdb36 --- /dev/null +++ b/templates/trends.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Trends — AHDX{% endblock %} +{% block body %} +

Trends

+ +
+ + + +
+ +{% if stats and stats['n'] %} +
+
{{ stats['n'] }}samples
+
{{ '%.1f'|format(stats['avg']) if stats['avg'] is not none else '–' }}avg {{ stats['unit'] or '' }}
+
{{ '%.1f'|format(stats['min']) if stats['min'] is not none else '–' }}min
+
{{ '%.1f'|format(stats['max']) if stats['max'] is not none else '–' }}max
+
+{% endif %} + +
+ +

+
+ + +{% endblock %}