This commit is contained in:
2026-09-01 15:48:43 +02:00
parent e274ece128
commit d928dfe316
6 changed files with 364 additions and 154 deletions
+125 -57
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import copy
import hashlib
import html
import re
from typing import Any
PLAN_SCHEMA_VERSION = 4
CONTRACT_VERSION = 3
PLAN_SCHEMA_VERSION = 5
CONTRACT_VERSION = 4
def slug(value: Any) -> str:
@@ -59,23 +60,102 @@ def _text_items(value: Any, fallback: Any = None) -> dict[str, list[str]]:
return {"items": items}
def _normalize_exercise(raw: Any, plan_id: str, index: int) -> dict[str, Any] | None:
def _html_text(value: Any) -> str:
return html.unescape(_string(value))
def _legacy_stage_lookup(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]:
stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {}
lookup: dict[str, dict[str, Any]] = {}
for key, stage in stages.items():
if not isinstance(stage, dict):
continue
for candidate in (key, stage.get("key"), stage.get("id")):
sid = _string(candidate)
if sid:
lookup[sid] = stage
return lookup
def _legacy_stage_for(raw: dict[str, Any], lookup: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
for candidate in (raw.get("key"), raw.get("progression_id"), raw.get("stage_id")):
sid = _string(candidate)
if sid and sid in lookup:
return lookup[sid]
return None
def _progression_sources(raw: dict[str, Any], stage: dict[str, Any] | None) -> list[Any]:
for key in ("progressions", "variants"):
value = raw.get(key)
if isinstance(value, list) and value:
return value
if isinstance(stage, dict) and isinstance(stage.get("steps"), list):
return stage["steps"]
return []
def _normalize_progressions(
raw: dict[str, Any],
plan_id: str,
exercise_id: str,
stage: dict[str, Any] | None,
) -> tuple[list[dict[str, Any]], str]:
progressions: list[dict[str, Any]] = []
seen: set[str] = set()
selected = _string(raw.get("current_progression_id"))
selected_name = _html_text(raw.get("current_progression"))
for index, item in enumerate(_progression_sources(raw, stage)):
if isinstance(item, dict):
name = _html_text(item.get("name") or item.get("title"))
else:
name = _html_text(item)
if not name:
continue
fingerprint = slug(name)
if fingerprint in seen:
continue
seen.add(fingerprint)
progression_id = _string(item.get("id")) if isinstance(item, dict) else ""
if not progression_id:
progression_id = stable_id("progression", plan_id, exercise_id, index, name)
progressions.append({"id": progression_id, "name": name})
if selected_name and slug(selected_name) == fingerprint:
selected = progression_id
ids = {item["id"] for item in progressions}
if selected not in ids:
selected = progressions[0]["id"] if progressions else ""
return progressions, selected
def _exercise_identity(raw: dict[str, Any], plan_id: str, index: int, name: str) -> str:
exercise_id = _string(raw.get("exercise_id") or raw.get("id"))
if exercise_id:
return exercise_id
key = _string(raw.get("key") or raw.get("progression_id"))
if key:
return stable_id("exercise", plan_id, key)
return stable_id("exercise", plan_id, slug(name or "uebung"))
def _normalize_exercise(
raw: Any,
plan_id: str,
index: int,
stage_lookup: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
name = _string(raw.get("name") or raw.get("title"))
exercise_id = _string(raw.get("id") or raw.get("exercise_id"))
if not exercise_id:
exercise_id = stable_id("exercise", plan_id, index, name or "uebung")
name = _html_text(raw.get("name") or raw.get("title"))
exercise_id = _exercise_identity(raw, plan_id, index, name)
exercise = {"id": exercise_id, "name": name}
for key in ("cue", "description", "equipment", "notes"):
value = _string(raw.get(key))
if value:
exercise[key] = value
tags = raw.get("tags")
if isinstance(tags, list):
clean_tags = [_string(tag) for tag in tags if _string(tag)]
if clean_tags:
exercise["tags"] = clean_tags
stage = _legacy_stage_for(raw, stage_lookup or {})
progressions, selected = _normalize_progressions(raw, plan_id, exercise_id, stage)
if progressions:
exercise["progressions"] = progressions
exercise["current_progression_id"] = selected
return exercise
@@ -107,15 +187,12 @@ def _legacy_day_exercises(day: dict[str, Any]) -> list[dict[str, Any]]:
for rotation in rotations:
if not isinstance(rotation, dict):
continue
label = _string(rotation.get("label"))
exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else []
for exercise in exercises:
if not isinstance(exercise, dict):
continue
item = copy.deepcopy(exercise)
item["__legacy_catalog"] = True
if label and not _string(item.get("notes")):
item["notes"] = label
result.append(item)
return result
@@ -125,24 +202,22 @@ def _ensure_catalog_entry(
raw: dict[str, Any],
plan_id: str,
index: int,
stage_lookup: dict[str, dict[str, Any]],
) -> str:
exercise_id = _string(raw.get("exercise_id") or raw.get("id"))
name = _string(raw.get("name") or raw.get("title"))
if not exercise_id:
exercise_id = stable_id("exercise", plan_id, index, name or "uebung")
name = _html_text(raw.get("name") or raw.get("title"))
exercise_id = _exercise_identity(raw, plan_id, index, name)
if exercise_id not in catalog:
source = {
"id": exercise_id,
"name": name,
"cue": raw.get("cue"),
"description": raw.get("description"),
"equipment": raw.get("equipment"),
"tags": raw.get("tags"),
}
catalog[exercise_id] = _normalize_exercise(source, plan_id, index) or {
source = {**raw, "id": exercise_id, "name": name}
catalog[exercise_id] = _normalize_exercise(source, plan_id, index, stage_lookup) or {
"id": exercise_id,
"name": name,
}
elif not catalog[exercise_id].get("progressions"):
source = {**raw, "id": exercise_id, "name": name}
exercise = _normalize_exercise(source, plan_id, index, stage_lookup)
if exercise and exercise.get("progressions"):
catalog[exercise_id]["progressions"] = exercise["progressions"]
catalog[exercise_id]["current_progression_id"] = exercise["current_progression_id"]
return exercise_id
@@ -150,16 +225,7 @@ def _normalize_day_exercise(raw: dict[str, Any], day_id: str, exercise_id: str,
item_id = _string(raw.get("id"))
if not item_id or item_id == exercise_id:
item_id = stable_id("day-exercise", day_id, exercise_id, index)
item: dict[str, Any] = {"id": item_id, "exercise_id": exercise_id}
for key in ("sets", "duration_seconds"):
value = _int(raw.get(key), 0)
if value > 0:
item[key] = value
for key in ("reps", "tempo", "rest", "notes", "legacy_id"):
value = _string(raw.get(key))
if value:
item[key] = value
return item
return {"id": item_id, "exercise_id": exercise_id}
def normalize_training_config(config: Any, plan_id: str | None = None) -> dict[str, Any]:
@@ -180,8 +246,9 @@ def normalize_training_config(config: Any, plan_id: str | None = None) -> dict[s
normalized["meta"]["subtitle"] = subtitle
catalog: dict[str, dict[str, Any]] = {}
stage_lookup = _legacy_stage_lookup(cfg)
for index, raw in enumerate(_catalog_sources(cfg)):
exercise = _normalize_exercise(raw, resolved_plan_id, index)
exercise = _normalize_exercise(raw, resolved_plan_id, index, stage_lookup)
if exercise:
catalog[exercise["id"]] = exercise
@@ -203,20 +270,17 @@ def normalize_training_config(config: Any, plan_id: str | None = None) -> dict[s
"cooldown": _text_items(raw_day.get("cooldown"), prepost.get("cooldown")),
"exercises": [],
}
focus = _string(raw_day.get("focus"))
if focus and focus != name:
day["focus"] = focus
notes = _string(raw_day.get("notes") or raw_day.get("note"))
if notes:
day["notes"] = notes
seen_day_exercises: set[str] = set()
for item_index, raw_item in enumerate(_legacy_day_exercises(raw_day)):
should_create = bool(raw_item.pop("__legacy_catalog", False) or _string(raw_item.get("name")))
if should_create:
exercise_id = _ensure_catalog_entry(catalog, raw_item, resolved_plan_id, exercise_counter)
exercise_id = _ensure_catalog_entry(catalog, raw_item, resolved_plan_id, exercise_counter, stage_lookup)
exercise_counter += 1
else:
exercise_id = _string(raw_item.get("exercise_id"))
if exercise_id in seen_day_exercises:
continue
seen_day_exercises.add(exercise_id)
day["exercises"].append(_normalize_day_exercise(raw_item, day_id, exercise_id, item_index))
normalized["days"].append(day)
@@ -254,6 +318,16 @@ def validate_training_config(config: Any) -> dict[str, Any]:
exercise_ids.add(_string(exercise.get("id")))
if not _string(exercise.get("name")):
errors.append({"code": "empty_exercise", "location": location, "message": "Übungsname fehlt."})
progression_ids: set[str] = set()
for progression_index, progression in enumerate(exercise.get("progressions", [])):
progression_location = f"{location}, Progression {progression_index + 1}"
register(progression.get("id"), progression_location)
progression_ids.add(_string(progression.get("id")))
if not _string(progression.get("name")):
errors.append({"code": "empty_progression", "location": progression_location, "message": "Progressionsname fehlt."})
current_progression_id = _string(exercise.get("current_progression_id"))
if current_progression_id and current_progression_id not in progression_ids:
errors.append({"code": "unknown_progression", "location": location, "message": "Aktuelle Progression existiert nicht in dieser Übung."})
if not cfg.get("days"):
warnings.append({"code": "no_days", "location": "Plan", "message": "Noch keine Trainingstage angelegt."})
@@ -271,11 +345,5 @@ def validate_training_config(config: Any) -> dict[str, Any]:
exercise_id = _string(item.get("exercise_id"))
if exercise_id not in exercise_ids:
errors.append({"code": "unknown_exercise", "location": item_location, "message": "Hinterlegte Übung existiert nicht in der Übungsbibliothek."})
sets = _int(item.get("sets"), 0)
if "sets" in item and not 1 <= sets <= 40:
errors.append({"code": "invalid_sets", "location": item_location, "message": "Sätze müssen zwischen 1 und 40 liegen."})
duration = _int(item.get("duration_seconds"), 0)
if "duration_seconds" in item and duration <= 0:
errors.append({"code": "invalid_duration", "location": item_location, "message": "Dauer muss größer als 0 sein."})
return {"errors": errors, "warnings": warnings, "config": cfg}