This commit is contained in:
2026-07-25 21:10:53 +02:00
parent e5bbc25c89
commit 6dd827b0b4
3 changed files with 509 additions and 30 deletions
+357 -1
View File
@@ -1234,6 +1234,8 @@ def _default_tracker(plan: dict[str, Any]) -> dict[str, Any]:
"revision": 1,
"source_file": plan["source_file"],
"source_hash": plan["source_hash"],
"plan_id": plan.get("plan_id"),
"plan_revision": plan.get("published_revision"),
"profile": {
"start_date": "",
"display_name": "",
@@ -1274,6 +1276,7 @@ def _load_tracker(plan: dict[str, Any]) -> dict[str, Any]:
tracker = _read_json(_tracker_path(plan["source_file"]), None)
if not isinstance(tracker, dict):
return _default_tracker(plan)
original_tracker = deepcopy(tracker)
tracker.setdefault("version", TRACKER_SCHEMA_VERSION)
tracker.setdefault("revision", 1)
tracker.setdefault("profile", {})
@@ -1290,7 +1293,18 @@ def _load_tracker(plan: dict[str, Any]) -> dict[str, Any]:
tracker.pop("analyses", None)
tracker["version"] = TRACKER_SCHEMA_VERSION
tracker["source_file"] = plan["source_file"]
tracker["source_changed"] = tracker.get("source_hash") not in (None, plan["source_hash"])
source_changed = tracker.get("source_hash") not in (None, plan["source_hash"])
tracker, migrated = _migrate_tracker_sessions(plan, tracker)
if migrated:
tracker["revision"] = int(tracker.get("revision") or 1) + 1
tracker["updated_at"] = _utc_now()
persisted = deepcopy(tracker)
persisted.pop("source_changed", None)
backup_path = SESSIONS_DIR / ".migration-backups" / f"{plan['source_file']}.pre-result-data-v2.json"
if not backup_path.exists():
_atomic_json_write(backup_path, original_tracker)
_atomic_json_write(_tracker_path(plan["source_file"]), persisted)
tracker["source_changed"] = source_changed
return tracker
@@ -1394,6 +1408,347 @@ def _format_result_data(value: Any) -> str:
return f"{prefix}{joined(data['values'])}{unit}{side}".strip()
def _item_has_user_data(item: Any) -> bool:
if not isinstance(item, dict):
return False
status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned"))
if status != "planned" or bool(item.get("done")):
return True
if isinstance(item.get("result_data"), dict):
return True
return any(_has_text(item.get(field)) for field in (
"result", "note", "progression", "exercise_name", "progression_id", "skip_reason"
))
def _merge_migrated_item(legacy: dict[str, Any], stable: dict[str, Any]) -> dict[str, Any]:
"""Vereinigt doppelte Legacy-/Stable-ID-Einträge ohne befüllte Werte zu verlieren.
Ein durch die neue UI angelegter leerer Stable-ID-Platzhalter darf einen
bereits abgeschlossenen Legacy-Eintrag insbesondere nicht wieder auf
``planned``/``done=false`` zurücksetzen.
"""
merged = deepcopy(legacy)
legacy_status = str(legacy.get("completion_status") or ("completed" if legacy.get("done") else "planned"))
stable_status = str(stable.get("completion_status") or ("completed" if stable.get("done") else "planned"))
for key, value in stable.items():
if key not in merged:
merged[key] = deepcopy(value)
continue
empty = value is None or value == "" or value == [] or value == {}
if empty:
continue
if key in {"done", "completion_status"} and stable_status == "planned" and legacy_status != "planned":
continue
merged[key] = deepcopy(value)
if stable_status != "planned":
merged["completion_status"] = stable_status
merged["done"] = stable_status in {"completed", "partial"}
elif legacy_status != "planned":
merged["completion_status"] = legacy_status
merged["done"] = legacy_status in {"completed", "partial"}
return merged
def _exercise_lookup_for_day(plan: dict[str, Any], day_num: int) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
by_id: dict[str, dict[str, Any]] = {}
legacy_to_stable: dict[str, str] = {}
day = next((entry for entry in plan.get("days", []) if isinstance(entry, dict) and int(entry.get("num") or 0) == day_num), None)
if not isinstance(day, dict):
return by_id, legacy_to_stable
for rotation in day.get("rotations", []) if isinstance(day.get("rotations"), list) else []:
if not isinstance(rotation, dict):
continue
for exercise in rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []:
if not isinstance(exercise, dict):
continue
stable_id = str(exercise.get("id") or "")
legacy_id = str(exercise.get("legacy_id") or "")
if stable_id:
by_id[stable_id] = exercise
if legacy_id and stable_id:
by_id[legacy_id] = exercise
legacy_to_stable[legacy_id] = stable_id
return by_id, legacy_to_stable
def _progression_step_for_item(plan: dict[str, Any], exercise: dict[str, Any], item: dict[str, Any]) -> dict[str, Any] | None:
progression_id = str(item.get("progression_id") or exercise.get("progression_id") or "")
stage = plan.get("stages", {}).get(progression_id) if isinstance(plan.get("stages"), dict) else None
steps = stage.get("steps") if isinstance(stage, dict) and isinstance(stage.get("steps"), list) else []
wanted_id = str(item.get("progression_step_id") or "")
if wanted_id:
found = next((step for step in steps if isinstance(step, dict) and str(step.get("id") or "") == wanted_id), None)
if found:
return found
wanted_name = _plain_text(item.get("progression") or "").casefold()
if wanted_name:
return next((
step for step in steps
if isinstance(step, dict) and _plain_text(step.get("name") or "").casefold() == wanted_name
), None)
return None
def _resolved_schema_for_item(plan: dict[str, Any], exercise: dict[str, Any], item: dict[str, Any]) -> dict[str, Any]:
schema = deepcopy(exercise.get("result_schema") if isinstance(exercise.get("result_schema"), dict) else {})
step = _progression_step_for_item(plan, exercise, item)
if isinstance(step, dict) and isinstance(step.get("result_schema"), dict):
schema.update(step["result_schema"])
manual = item.get("result_format")
if isinstance(manual, dict):
for key in ("mode", "weight_mode", "laterality", "sides_mode", "sets", "default_sets", "locked_sets"):
if key in manual:
schema[key] = manual[key]
mode = str(schema.get("mode") or "auto")
if mode not in {"auto", "reps", "seconds", "minutes", "none"}:
mode = "auto"
weight_mode = str(schema.get("weight_mode") or "none")
if weight_mode not in {"none", "optional", "required"}:
weight_mode = "none"
laterality = str(schema.get("laterality") or "bilateral")
if laterality not in {"bilateral", "unilateral"}:
laterality = "bilateral"
sides_mode = str(schema.get("sides_mode") or "same")
if sides_mode not in {"same", "separate"}:
sides_mode = "same"
try:
sets = max(1, min(20, int(schema.get("sets") or schema.get("default_sets") or 1)))
except (TypeError, ValueError):
sets = 1
return {
"mode": mode,
"weight_mode": weight_mode,
"laterality": laterality,
"sides_mode": sides_mode if laterality == "unilateral" else "same",
"default_sets": sets,
"locked_sets": bool(schema.get("locked_sets")),
}
def _parse_legacy_result_data(value: Any, schema: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""Überführt eindeutige alte Freitextergebnisse verlustfrei in result_data 2.
Die historische Eingabe ist für die konkrete Session bindend. Weicht ihre
eindeutig erkennbare Messart vom heutigen Plan ab, wird deshalb zusätzlich
ein manueller ``result_format``-Override gespeichert, statt Werte zu verwerfen.
"""
raw = str(value or "").strip()
if not raw:
return None, None
low = raw.lower().replace("×", "x")
explicit_mode: str | None = None
if re.search(r"(?:\d|\s)(?:min\.?|minuten)\b", low, flags=re.I):
explicit_mode = "minutes"
elif re.search(r"(?:\d|\s)(?:s|sek\.?|sekunden)\b", low, flags=re.I):
explicit_mode = "seconds"
recommended_mode = str(schema.get("mode") or "auto")
mode = explicit_mode or (recommended_mode if recommended_mode in {"reps", "seconds", "minutes"} else "reps")
weight_match = re.search(r"(\d+(?:[.,]\d+)?)\s*kg\b", low, flags=re.I)
weight = _result_number(weight_match.group(1)) if weight_match else None
def number_list(part: Any) -> list[float | None]:
return [
number for number in (_result_number(match.group(0)) for match in re.finditer(r"\d+(?:[.,]\d+)?", str(part or "")))
if number is not None
]
def clean_part(part: Any) -> str:
text = re.sub(r"\d+(?:[.,]\d+)?\s*kg\b", " ", str(part or ""), flags=re.I)
return re.sub(
r"\b(?:reps?|wiederholungen|s|sek\.?|sekunden|min\.?|minuten|je seite|pro seite)\b",
" ", text, flags=re.I,
)
separate = re.search(
r"\bL(?:inks)?\s*:?\s*([^·;|]+)[·;|]\s*R(?:echts)?\s*:?\s*(.+)$",
raw, flags=re.I,
)
if separate:
left = number_list(clean_part(separate.group(1)))
right = number_list(clean_part(separate.group(2)))
if not left and not right:
return None, None
sets = max(len(left), len(right), 1)
data = _sanitize_result_data({
"version": RESULT_DATA_VERSION,
"mode": mode,
"laterality": "unilateral",
"sides_mode": "separate",
"sets": sets,
"weight_kg": weight,
"values": [],
"left_values": left,
"right_values": right,
})
else:
work = clean_part(raw)
repeated = re.search(r"(\d+(?:[.,]\d+)?)\s*x\s*(\d+(?:[.,]\d+)?)", work, flags=re.I)
if repeated:
count = max(1, min(20, int(float(repeated.group(1).replace(",", ".")))))
repeated_value = _result_number(repeated.group(2))
values = [repeated_value] * count if repeated_value is not None else []
else:
values = number_list(work)
if not values and weight is None:
return None, None
per_side = bool(re.search(r"(?:je|pro)\s+seite|/\s*seite", low, flags=re.I))
laterality = "unilateral" if per_side else str(schema.get("laterality") or "bilateral")
if laterality not in {"bilateral", "unilateral"}:
laterality = "bilateral"
# Eine einzige historische Zahlenreihe ist nie eine belastbare Links-/
# Rechts-Trennung. Bei einseitigen Übungen bedeutet sie daher "je Seite".
sides_mode = "same"
sets = max(1, min(20, len(values) or int(schema.get("default_sets") or 1)))
data = _sanitize_result_data({
"version": RESULT_DATA_VERSION,
"mode": mode,
"laterality": laterality,
"sides_mode": sides_mode,
"sets": sets,
"weight_kg": weight,
"values": values,
"left_values": [],
"right_values": [],
})
if data is None:
return None, None
actual_weight_mode = str(schema.get("weight_mode") or "none")
if weight is not None and actual_weight_mode == "none":
actual_weight_mode = "optional"
differs = (
data["mode"] != recommended_mode
or data["laterality"] != str(schema.get("laterality") or "bilateral")
or data["sides_mode"] != str(schema.get("sides_mode") or "same")
or actual_weight_mode != str(schema.get("weight_mode") or "none")
)
result_format = None
if differs:
result_format = {
"mode": data["mode"],
"laterality": data["laterality"],
"sides_mode": data["sides_mode"],
"weight_mode": actual_weight_mode,
}
return data, result_format
def _migrate_tracker_sessions(plan: dict[str, Any], tracker: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Migriert alle vorhandenen Sessions serverseitig und atomar auf stabile IDs.
Dadurch hängt die Datenmigration nicht davon ab, welche Session zufällig im
Browser geöffnet oder verändert wurde. Uneindeutige Freitexte bleiben als
``result`` erhalten; eindeutige Werte werden zusätzlich strukturiert gespeichert.
"""
changed = False
tracker = deepcopy(tracker)
desired_root = {
"version": TRACKER_SCHEMA_VERSION,
"source_file": plan.get("source_file"),
"source_hash": plan.get("source_hash"),
"plan_id": plan.get("plan_id"),
"plan_revision": plan.get("published_revision"),
}
for key, value in desired_root.items():
if tracker.get(key) != value:
tracker[key] = value
changed = True
sessions = tracker.get("sessions") if isinstance(tracker.get("sessions"), dict) else {}
if tracker.get("sessions") is not sessions:
tracker["sessions"] = sessions
changed = True
for session_key, session in sessions.items():
if not isinstance(session, dict):
continue
_, day_num = _session_key_parts(str(session_key))
if day_num == 9999:
continue
by_id, legacy_to_stable = _exercise_lookup_for_day(plan, day_num)
items = session.get("items") if isinstance(session.get("items"), dict) else {}
if session.get("items") is not items:
session["items"] = items
changed = True
for legacy_id, stable_id in list(legacy_to_stable.items()):
if legacy_id not in items or legacy_id == stable_id:
continue
legacy_item = items.pop(legacy_id)
if isinstance(legacy_item, dict):
stable_item = items.get(stable_id)
items[stable_id] = _merge_migrated_item(
legacy_item,
stable_item if isinstance(stable_item, dict) else {},
)
changed = True
for item_id, item in list(items.items()):
if not isinstance(item, dict):
continue
exercise = by_id.get(str(item_id))
status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned"))
if status not in VALID_ITEM_STATUSES:
status = "planned"
if item.get("completion_status") != status:
item["completion_status"] = status
changed = True
done = status in {"completed", "partial"}
if bool(item.get("done")) != done:
item["done"] = done
changed = True
if status != "skipped" and "skip_reason" in item:
item.pop("skip_reason", None)
changed = True
if isinstance(exercise, dict) and _item_has_user_data(item):
metadata = {
"progression_id": str(item.get("progression_id") or exercise.get("progression_id") or ""),
"exercise_name": _plain_text(item.get("exercise_name") or exercise.get("name") or ""),
}
for key, value in metadata.items():
if value and item.get(key) != value:
item[key] = value
changed = True
step = _progression_step_for_item(plan, exercise, item)
if isinstance(step, dict) and step.get("id") and not item.get("progression_step_id"):
item["progression_step_id"] = str(step["id"])
changed = True
if isinstance(item.get("result_data"), dict):
data = _sanitize_result_data(item.get("result_data"))
if data is not None:
formatted = _format_result_data(data)
if item.get("result_data") != data:
item["result_data"] = data
changed = True
if item.get("result") != formatted:
item["result"] = formatted
changed = True
else:
item.pop("result_data", None)
changed = True
elif isinstance(exercise, dict) and _has_text(item.get("result")):
schema = _resolved_schema_for_item(plan, exercise, item)
data, result_format = _parse_legacy_result_data(item.get("result"), schema)
if data is not None:
item["result_data"] = data
item["result"] = _format_result_data(data)
if result_format is not None:
item["result_format"] = result_format
changed = True
for key, value in {
"plan_id": plan.get("plan_id"),
"plan_revision": plan.get("published_revision"),
}.items():
if session.get(key) != value:
session[key] = value
changed = True
return tracker, changed
def _normalize_tracker_payload(plan: dict[str, Any], incoming: Any, existing: dict[str, Any]) -> dict[str, Any]:
if not isinstance(incoming, dict):
raise ValueError("Tracker-Daten müssen ein JSON-Objekt sein")
@@ -1436,6 +1791,7 @@ def _normalize_tracker_payload(plan: dict[str, Any], incoming: Any, existing: di
for week, status in list(clean["week_statuses"].items()):
if not isinstance(status, dict): clean["week_statuses"].pop(week, None); continue
status["status"] = "closed" if status.get("status") == "closed" else "open"
clean, _ = _migrate_tracker_sessions(plan, clean)
return clean