# -*- 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)