UI
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user