""" Apple-Health-Schlaf-CSV → sleep_log (für Universal-Import und /api/sleep/import). Nutzt dieselbe Logik wie der Sleep-Router, ohne HTTPException — arbeitet auf einem übergebenen Cursor. """ from __future__ import annotations import csv import io import json import logging from datetime import date, datetime from decimal import Decimal, InvalidOperation from typing import Any, Literal logger = logging.getLogger(__name__) def _strip_row_keys(row: dict) -> dict: return {(k or "").strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()} def _safe_float(value: Any) -> float | None: if value is None or value == "": return None if isinstance(value, (int, float)): return float(value) if isinstance(value, Decimal): return float(value) try: s = str(value).strip().replace(",", ".") return float(s) except (ValueError, InvalidOperation): return None def _parse_apple_sleep_datetime(value: str) -> datetime: raw = (value or "").strip() if not raw: raise ValueError("empty datetime") fmts = ( "%Y-%m-%d %H:%M:%S %z", "%d.%m.%y %H:%M:%S", "%d.%m.%Y %H:%M:%S", "%Y-%m-%d %H:%M:%S", ) last_err = None for fmt in fmts: try: return datetime.strptime(raw, fmt) except ValueError as e: last_err = e raise ValueError(f"Unbekanntes Datumsformat: {raw!r}") from last_err def _hr_to_minutes(hours: float | None) -> int: if hours is None: return 0 return int(round(float(hours) * 60)) def detect_apple_sleep_csv_format(fieldnames: list[str] | None) -> Literal["segments", "summary"]: if not fieldnames: raise ValueError("CSV enthält keine Spaltenüberschriften.") fn = {(f or "").strip() for f in fieldnames} if {"Start", "End", "Duration (hr)", "Value"}.issubset(fn): return "segments" if "Start" in fn and "End" in fn and "Total Sleep (hr)" in fn: return "summary" raise ValueError( "Unbekanntes Apple-Health-Schlaf-CSV. Erwartet: Segment-Export oder Schlafanalyse-Zusammenfassung." ) def _build_nights_from_apple_summary(reader: csv.DictReader) -> dict[date, dict]: nights_dict: dict[date, dict] = {} for raw in reader: row = _strip_row_keys(raw) start_s = row.get("Start") or "" end_s = row.get("End") or "" if not start_s or not end_s: continue try: start_dt = _parse_apple_sleep_datetime(start_s) end_dt = _parse_apple_sleep_datetime(end_s) except ValueError: continue dt_key = (row.get("Date/Time") or row.get("Datum/Uhrzeit") or "").strip() if dt_key: try: wake_d = datetime.strptime(dt_key[:10], "%Y-%m-%d").date() except ValueError: wake_d = end_dt.date() else: wake_d = end_dt.date() core_hr = _safe_float(row.get("Core (hr)")) if core_hr is None: core_hr = _safe_float(row.get("Light (hr)")) or 0.0 deep_min = _hr_to_minutes(_safe_float(row.get("Deep (hr)"))) rem_min = _hr_to_minutes(_safe_float(row.get("REM (hr)"))) light_min = _hr_to_minutes(core_hr) awake_min = _hr_to_minutes(_safe_float(row.get("Awake (hr)"))) nights_dict[wake_d] = { "bedtime": start_dt, "wake_time": end_dt, "segments": [], "deep_minutes": deep_min, "rem_minutes": rem_min, "light_minutes": light_min, "awake_minutes": awake_min, } return nights_dict def _build_nights_from_apple_segments(reader: csv.DictReader, phase_map: dict) -> dict[date, dict]: segments = [] for raw in reader: row = _strip_row_keys(raw) phase_key = (row.get("Value") or "").strip() phase_en = phase_map.get(phase_key) if phase_en is None: continue try: start_dt = _parse_apple_sleep_datetime(row.get("Start") or "") end_dt = _parse_apple_sleep_datetime(row.get("End") or "") duration_hr = _safe_float(row.get("Duration (hr)")) if duration_hr is None: continue except (ValueError, TypeError): continue duration_min = int(duration_hr * 60) segments.append({ "start": start_dt, "end": end_dt, "duration_min": duration_min, "phase": phase_en, }) segments.sort(key=lambda s: s["start"]) nights = [] current_night = None for seg in segments: if current_night is None or (seg["start"] - current_night["wake_time"]).total_seconds() > 7200: current_night = { "bedtime": seg["start"], "wake_time": seg["end"], "segments": [], "deep_minutes": 0, "rem_minutes": 0, "light_minutes": 0, "awake_minutes": 0, } nights.append(current_night) current_night["segments"].append(seg) current_night["wake_time"] = max(current_night["wake_time"], seg["end"]) current_night["bedtime"] = min(current_night["bedtime"], seg["start"]) if seg["phase"] == "deep": current_night["deep_minutes"] += seg["duration_min"] elif seg["phase"] == "rem": current_night["rem_minutes"] += seg["duration_min"] elif seg["phase"] == "light": current_night["light_minutes"] += seg["duration_min"] elif seg["phase"] == "awake": current_night["awake_minutes"] += seg["duration_min"] nights_dict: dict[date, dict] = {} for night in nights: wake_date = night["wake_time"].date() nights_dict[wake_date] = night return nights_dict def import_apple_sleep_nights(cur, profile_id: str, text: str) -> dict[str, Any]: """ Schreibt in sleep_log. Kein conn.commit — Aufrufer rollt Transaktion. Gibt Statistik im Executor-Format zurück. """ csv_text = text.replace("\r\n", "\n").replace("\r", "\n") if csv_text.startswith("\ufeff"): csv_text = csv_text[1:] reader = csv.DictReader(io.StringIO(csv_text)) fmt = detect_apple_sleep_csv_format(reader.fieldnames) phase_map = { "Kern": "light", "Core": "light", "Light": "light", "REM": "rem", "Tief": "deep", "Deep": "deep", "Wach": "awake", "Awake": "awake", "Schlafend": None, "Asleep": None, "In Bed": None, } if fmt == "summary": nights_dict = _build_nights_from_apple_summary(reader) else: nights_dict = _build_nights_from_apple_segments(reader, phase_map) if not nights_dict: raise ValueError( "Keine importierbaren Schlafzeilen gefunden (prüfe Start/Ende und Format)." ) inserted = 0 updated = 0 skipped = 0 error_details: list[dict[str, Any]] = [] affected_ids: list[str] = [] row_hint = 0 for wake_date, night in nights_dict.items(): row_hint += 1 duration_minutes = ( night["deep_minutes"] + night["rem_minutes"] + night["light_minutes"] ) wake_count = sum(1 for seg in night["segments"] if seg["phase"] == "awake") sleep_segments = [ { "phase": seg["phase"], "start": seg["start"].isoformat(), "end": seg["end"].isoformat(), "duration_min": seg["duration_min"], } for seg in night["segments"] ] cur.execute( """ SELECT id, source FROM sleep_log WHERE profile_id = %s AND date = %s """, (profile_id, wake_date), ) existing = cur.fetchone() if existing and existing["source"] == "manual": skipped += 1 continue try: if existing: cur.execute( """ UPDATE sleep_log SET bedtime = %s, wake_time = %s, duration_minutes = %s, wake_count = %s, deep_minutes = %s, rem_minutes = %s, light_minutes = %s, awake_minutes = %s, sleep_segments = %s, source = 'apple_health', updated_at = CURRENT_TIMESTAMP WHERE id = %s AND profile_id = %s RETURNING id """, ( night["bedtime"].time(), night["wake_time"].time(), duration_minutes, wake_count, night["deep_minutes"], night["rem_minutes"], night["light_minutes"], night["awake_minutes"], json.dumps(sleep_segments), existing["id"], profile_id, ), ) row = cur.fetchone() updated += 1 if row and row.get("id"): affected_ids.append(str(row["id"])) else: cur.execute( """ INSERT INTO sleep_log ( profile_id, date, bedtime, wake_time, duration_minutes, wake_count, deep_minutes, rem_minutes, light_minutes, awake_minutes, sleep_segments, source, created_at, updated_at ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'apple_health', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) RETURNING id """, ( profile_id, wake_date, night["bedtime"].time(), night["wake_time"].time(), duration_minutes, wake_count, night["deep_minutes"], night["rem_minutes"], night["light_minutes"], night["awake_minutes"], json.dumps(sleep_segments), ), ) row = cur.fetchone() inserted += 1 if row and row.get("id"): affected_ids.append(str(row["id"])) except Exception as e: logger.warning("Sleep import row failed: %s", e) error_details.append({"row": row_hint, "error": str(e)}) return { "rows_total": len(nights_dict), "inserted": inserted, "updated": updated, "skipped": skipped, "new_entries": inserted, "error_details": error_details, "affected_ids": affected_ids, }