# -*- coding: utf-8 -*- """Schlanke Plan-App fuer den aktuellen Trainingsplan. Der alte Session-Tracker hat Ergebnisse, Wochen, Analysen und Vorschlaege verwaltet. Die neue App arbeitet nur noch mit der Wahrheit im Plan: Uebungen haben Progressionsvarianten, und genau eine davon ist aktuell. """ from __future__ import annotations import json import os import re import sys import tempfile import threading from copy import deepcopy from pathlib import Path from typing import Any from flask import Flask, Response, jsonify, request PLUGIN_DIR = Path(__file__).resolve().parent STATIC_DIR = PLUGIN_DIR / "static" TRAININGSPLAN_DIR = PLUGIN_DIR.parent / "trainingsplan" if str(TRAININGSPLAN_DIR) not in sys.path: sys.path.insert(0, str(TRAININGSPLAN_DIR)) from schema_contract import CONTRACT_VERSION, PLAN_SCHEMA_VERSION, normalize_training_config, validate_training_config # noqa: E402 from storage import utc_now # noqa: E402 _WRITE_LOCK = threading.RLock() def _discover_root() -> Path: explicit = os.environ.get("BOEHMITOOLS_ROOT", "").strip() if explicit: return Path(explicit).expanduser().resolve() for candidate in ( Path("/home/michael/boehmitools/runtime/training"), Path("/home/michael/runtime/training"), ): if (candidate / "data" / "trainingsplan" / "plans").is_dir(): return candidate.resolve() if PLUGIN_DIR.parent.name == "plugins": return PLUGIN_DIR.parent.parent.resolve() return PLUGIN_DIR.resolve() ROOT_DIR = _discover_root() DATA_DIR = Path(os.environ.get("TRAININGSTRACKER_DATA_DIR", ROOT_DIR / "data" / "trainingstracker")).expanduser().resolve() PLANS_DIR = Path(os.environ.get("TRAININGSTRACKER_PLANS_DIR", ROOT_DIR / "data" / "trainingsplan" / "plans")).expanduser().resolve() SETTINGS_FILE = DATA_DIR / "settings.json" DATA_DIR.mkdir(parents=True, exist_ok=True) app = Flask(__name__, static_folder=None) def _read_json(path: Path, default: Any = None) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError, OSError): return deepcopy(default) def _atomic_json_write(path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) with _WRITE_LOCK: fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(temp_name, path) finally: if os.path.exists(temp_name): os.unlink(temp_name) def _plain(value: Any) -> str: text = str(value or "") text = re.sub(r"", "\n", text, flags=re.I) text = re.sub(r"<[^>]+>", "", text) return text.strip() def _unwrap(raw: Any, fallback_name: str) -> tuple[str, dict[str, Any], dict[str, Any]]: if not isinstance(raw, dict): raw = {} if isinstance(raw.get("draft"), dict): cfg = raw["draft"] elif isinstance(raw.get("config"), dict): cfg = raw["config"] else: cfg = raw meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} name = _plain(raw.get("name") or meta.get("title") or fallback_name) return name, cfg, raw def _is_plan(path: Path) -> bool: raw = _read_json(path, {}) _, cfg, _ = _unwrap(raw, path.stem) if not isinstance(cfg, dict) or cfg.get("type") == "recipe": return False return isinstance(cfg.get("days"), list) def _known_plan_files() -> dict[str, Path]: if not PLANS_DIR.is_dir(): return {} return { path.name: path for path in sorted(PLANS_DIR.glob("*.json"), key=lambda item: item.name.casefold()) if _is_plan(path) } def _validated_plan_path(plan_id: str) -> Path | None: return _known_plan_files().get(Path(plan_id).name) def _read_settings() -> dict[str, Any]: settings = _read_json(SETTINGS_FILE, {}) return settings if isinstance(settings, dict) else {} def _save_selected(plan_id: str) -> None: settings = _read_settings() settings["selected_plan"] = Path(plan_id).name settings["updated_at"] = utc_now() _atomic_json_write(SETTINGS_FILE, settings) def _exercise_map(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]: return {str(exercise.get("id")): exercise for exercise in cfg.get("exercises", []) if isinstance(exercise, dict)} def _current_progression(exercise: dict[str, Any]) -> dict[str, str] | None: progressions = exercise.get("progressions") if isinstance(exercise.get("progressions"), list) else [] current_id = str(exercise.get("current_progression_id") or "") for progression in progressions: if isinstance(progression, dict) and str(progression.get("id") or "") == current_id: return {"id": str(progression.get("id") or ""), "name": str(progression.get("name") or "")} for progression in progressions: if isinstance(progression, dict): return {"id": str(progression.get("id") or ""), "name": str(progression.get("name") or "")} return None def _public_config(cfg: dict[str, Any]) -> dict[str, Any]: cfg = normalize_training_config(cfg) exercises = _exercise_map(cfg) days = [] for day in cfg.get("days", []): if not isinstance(day, dict): continue enriched = { "id": str(day.get("id") or ""), "num": int(day.get("num") or len(days) + 1), "name": str(day.get("name") or f"Tag {len(days) + 1}"), "warmup": deepcopy(day.get("warmup") if isinstance(day.get("warmup"), dict) else {"items": []}), "cooldown": deepcopy(day.get("cooldown") if isinstance(day.get("cooldown"), dict) else {"items": []}), "exercises": [], } for item in day.get("exercises", []): if not isinstance(item, dict): continue exercise = exercises.get(str(item.get("exercise_id") or "")) if not exercise: continue public_exercise = deepcopy(exercise) public_exercise["current_progression"] = _current_progression(exercise) enriched["exercises"].append({ "id": str(item.get("id") or ""), "exercise_id": str(exercise.get("id") or ""), "exercise": public_exercise, }) days.append(enriched) cfg["days"] = days for exercise in cfg.get("exercises", []): if isinstance(exercise, dict): exercise["current_progression"] = _current_progression(exercise) return cfg def _read_plan(path: Path) -> dict[str, Any]: raw = _read_json(path, {}) name, cfg, wrapper = _unwrap(raw, path.stem) normalized = normalize_training_config(cfg, plan_id=wrapper.get("plan_id") or cfg.get("plan_id") or path.stem) return { "id": path.name, "name": name, "source_file": path.name, "revision": int(wrapper.get("revision") or 1) if isinstance(wrapper, dict) else 1, "published_revision": int(wrapper.get("published_revision") or 1) if isinstance(wrapper, dict) else 1, "updated_at": str(wrapper.get("updated_at") or "") if isinstance(wrapper, dict) else "", "config": _public_config(normalized), } def _write_plan_config(path: Path, cfg: dict[str, Any]) -> dict[str, Any]: raw = _read_json(path, {}) name, _, wrapper = _unwrap(raw, path.stem) normalized = normalize_training_config(cfg, plan_id=cfg.get("plan_id") or path.stem) validation = validate_training_config(normalized) if validation["errors"]: raise ValueError(json.dumps(validation["errors"], ensure_ascii=False)) normalized = validation["config"] now = utc_now() if isinstance(wrapper, dict) and ("config" in wrapper or "draft" in wrapper): wrapper["name"] = name wrapper["plan_id"] = normalized.get("plan_id") or path.stem wrapper["config"] = deepcopy(normalized) wrapper["draft"] = deepcopy(normalized) wrapper["revision"] = int(wrapper.get("revision") or 1) + 1 wrapper["published_revision"] = int(wrapper.get("published_revision") or 1) + 1 wrapper["updated_at"] = now wrapper["published_at"] = now payload = wrapper else: payload = {"name": name, "config": normalized, "updated_at": now} _atomic_json_write(path, payload) return _read_plan(path) @app.route("/") def index(): source = (STATIC_DIR / "index.html").read_text(encoding="utf-8") return Response(source.replace("__APP_BASE__", request.script_root.rstrip("/")), mimetype="text/html") @app.route("/api/health") def api_health(): return jsonify({ "ok": True, "name": "Trainingstracker", "plans_dir": str(PLANS_DIR), "data_dir": str(DATA_DIR), "contract": {"plan_schema": PLAN_SCHEMA_VERSION, "training_contract": CONTRACT_VERSION}, }) @app.route("/api/plans") def api_plans(): files = _known_plan_files() settings = _read_settings() selected = settings.get("selected_plan") if selected not in files: selected = next(iter(files), None) plans = [] for plan_id, path in files.items(): plan = _read_plan(path) cfg = plan["config"] plans.append({ "id": plan_id, "name": plan["name"], "selected": plan_id == selected, "days": len(cfg.get("days", [])), "exercises": len(cfg.get("exercises", [])), }) return jsonify({"plans": plans, "selected": selected}) @app.route("/api/plans//select", methods=["POST"]) def api_select_plan(plan_id: str): path = _validated_plan_path(plan_id) if not path: return jsonify({"error": "Plan nicht gefunden."}), 404 _save_selected(path.name) return jsonify({"ok": True, "selected": path.name}) @app.route("/api/plans/") def api_plan(plan_id: str): path = _validated_plan_path(plan_id) if not path: return jsonify({"error": "Plan nicht gefunden."}), 404 return jsonify({"plan": _read_plan(path)}) @app.route("/api/plans//exercises//current", methods=["PATCH"]) def api_update_current_progression(plan_id: str, exercise_id: str): path = _validated_plan_path(plan_id) if not path: return jsonify({"error": "Plan nicht gefunden."}), 404 body = request.get_json(silent=True) body = body if isinstance(body, dict) else {} progression_id = str(body.get("current_progression_id") or "").strip() plan = _read_plan(path) cfg = deepcopy(plan["config"]) exercise = next((item for item in cfg.get("exercises", []) if str(item.get("id")) == exercise_id), None) if not exercise: return jsonify({"error": "Übung nicht gefunden."}), 404 ids = {str(item.get("id") or "") for item in exercise.get("progressions", []) if isinstance(item, dict)} if progression_id not in ids: return jsonify({"error": "Progression nicht gefunden."}), 400 exercise["current_progression_id"] = progression_id try: saved = _write_plan_config(path, cfg) except ValueError as exc: return jsonify({"error": str(exc)}), 400 return jsonify({"ok": True, "plan": saved}) if __name__ == "__main__": app.run(host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", "8081")), debug=False)