311 lines
11 KiB
Python
311 lines
11 KiB
Python
# -*- 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"<br\s*/?>", "\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/<path:plan_id>/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/<path:plan_id>")
|
|
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/<path:plan_id>/exercises/<exercise_id>/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)
|