INITIAL COMMIT

This commit is contained in:
2026-06-16 15:14:37 +02:00
commit 1477ec36fd
49 changed files with 6835 additions and 0 deletions

View File

@ -0,0 +1,354 @@
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from datetime import date, datetime
from typing import Any
from garminconnect.workout import (
CyclingWorkout,
WorkoutSegment,
create_cooldown_step,
create_interval_step,
create_recovery_step,
create_repeat_group,
create_warmup_step,
)
CYCLING_SPORT = {"sportTypeId": 2, "sportTypeKey": "cycling", "displayOrder": 2}
CLONE_ID_FIELDS = {
"author",
"createdDate",
"ownerId",
"shared",
"stepId",
"updatedDate",
"workoutId",
}
VALID_STEP_TYPES = {
"warmup",
"cooldown",
"interval",
"recovery",
"rest",
"repeat",
"other",
"main",
}
def build_dummy_cycling_workout(name: str | None = None) -> dict[str, Any]:
workout = CyclingWorkout(
workoutName=name or f"GCClone Probe Dummy {datetime.now():%Y-%m-%d %H:%M}",
description="Minimal cycling workout uploaded by the Garmin Coach clone probe.",
estimatedDurationInSecs=14 * 60,
workoutSegments=[
WorkoutSegment(
segmentOrder=1,
sportType=CYCLING_SPORT,
workoutSteps=[
create_warmup_step(5 * 60, step_order=1),
create_repeat_group(
iterations=1,
step_order=2,
workout_steps=[
create_interval_step(2 * 60, step_order=3),
create_recovery_step(2 * 60, step_order=4),
],
),
create_cooldown_step(5 * 60, step_order=5),
],
)
],
)
return workout.to_dict()
def clone_workout_payload(
source: dict[str, Any], scheduled_date: date, prefix: str
) -> dict[str, Any]:
if "workoutSegments" not in source:
raise ValueError("source object does not contain workoutSegments")
payload = deepcopy(source)
_strip_ids(payload)
original_name = str(
source.get("workoutName")
or source.get("name")
or source.get("title")
or "Garmin Coach Workout"
)
payload["workoutName"] = f"{prefix} {scheduled_date.isoformat()} {original_name}"[:120]
payload["description"] = (
"Generated by garmin-coach-to-cal-sync probe from a Garmin Coach/adaptive "
"workout-like object. Verify targets on the Edge before relying on it."
)
payload["sportType"] = CYCLING_SPORT
for idx, segment in enumerate(payload.get("workoutSegments", []), start=1):
if isinstance(segment, dict):
segment["segmentOrder"] = segment.get("segmentOrder") or idx
segment["sportType"] = CYCLING_SPORT
payload.setdefault("estimatedDurationInSecs", estimate_duration(payload))
return payload
def validate_workout_payload(workout: dict[str, Any]) -> list[str]:
errors: list[str] = []
name = workout.get("workoutName")
if not isinstance(name, str) or not name.strip():
errors.append("workoutName must be a non-empty string")
sport_key = _key(workout.get("sportType"))
if sport_key != "cycling":
errors.append(f"sportType must be cycling, got {sport_key or '<missing>'}")
duration = workout.get("estimatedDurationInSecs")
if duration is not None and (not isinstance(duration, int) or duration < 0):
errors.append("estimatedDurationInSecs must be a non-negative integer when present")
segments = workout.get("workoutSegments")
if not isinstance(segments, list) or not segments:
errors.append("workoutSegments must be a non-empty list")
return errors
for segment_idx, segment in enumerate(segments, start=1):
path = f"workoutSegments[{segment_idx}]"
if not isinstance(segment, dict):
errors.append(f"{path} must be an object")
continue
if _key(segment.get("sportType")) != "cycling":
errors.append(f"{path}.sportType must be cycling")
steps = segment.get("workoutSteps")
if not isinstance(steps, list) or not steps:
errors.append(f"{path}.workoutSteps must be a non-empty list")
continue
_validate_steps(steps, f"{path}.workoutSteps", errors)
return errors
def estimate_duration(workout: dict[str, Any]) -> int:
total = 0
for segment in workout.get("workoutSegments", []):
if isinstance(segment, dict):
total += _steps_duration(segment.get("workoutSteps", []), multiplier=1)
return total
def workout_source_hash(workout: dict[str, Any]) -> str:
value = deepcopy(workout)
_strip_ids(value)
for key in (
"uploadTimestamp",
"workoutThumbnailUrl",
"sharedWithUsers",
"consumer",
"consumerImageURL",
"consumerName",
"consumerWebsiteURL",
):
value.pop(key, None)
encoded = json.dumps(value, separators=(",", ":"), sort_keys=True)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def existing_clone_names(
workouts: list[dict[str, Any]], scheduled_date: date, prefix: str
) -> list[str]:
marker = f"{prefix} {scheduled_date.isoformat()}"
return [
str(workout.get("workoutName"))
for workout in workouts
if str(workout.get("workoutName", "")).startswith(marker)
]
def generated_workouts(workouts: list[dict[str, Any]], prefix: str) -> list[dict[str, Any]]:
return [
workout
for workout in workouts
if str(workout.get("workoutName", "")).startswith(prefix)
]
def find_generated_workout(
workouts: list[dict[str, Any]], workout_id: str, prefix: str
) -> dict[str, Any] | None:
for workout in generated_workouts(workouts, prefix):
if str(workout.get("workoutId") or workout.get("id")) == str(workout_id):
return workout
return None
def generated_calendar_entries(calendar_data: Any, prefix: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
def walk(node: Any) -> None:
if isinstance(node, dict):
name = calendar_entry_name(node)
if name is not None and name.startswith(prefix) and calendar_entry_id(node) is not None:
entries.append(node)
for child in node.values():
walk(child)
elif isinstance(node, list):
for child in node:
walk(child)
walk(calendar_data)
return _dedupe_calendar_entries(entries)
def find_generated_calendar_entry(
calendar_data: Any, scheduled_id: str, prefix: str
) -> dict[str, Any] | None:
for entry in generated_calendar_entries(calendar_data, prefix):
if calendar_entry_id(entry) == str(scheduled_id):
return entry
return None
def calendar_entry_id(entry: dict[str, Any]) -> str | None:
for key in ("scheduledWorkoutId", "calendarItemId"):
value = entry.get(key)
if value is not None:
return str(value)
return None
def calendar_entry_name(entry: dict[str, Any]) -> str | None:
for key in ("workoutName", "name", "title"):
value = entry.get(key)
if isinstance(value, str) and value:
return value
for key in ("workout", "workoutDTO", "workoutSummary"):
nested = entry.get(key)
if isinstance(nested, dict):
name = calendar_entry_name(nested)
if name is not None:
return name
return None
def calendar_entry_date(entry: dict[str, Any]) -> str | None:
for key in ("date", "startDate", "scheduledDate", "calendarDate"):
value = entry.get(key)
if isinstance(value, str) and value:
return value[:10]
return None
def summarize_workout(workout: dict[str, Any]) -> str:
lines: list[str] = []
name = workout.get("workoutName") or workout.get("name") or workout.get("title") or "<unnamed>"
lines.append(f"Workout: {name}")
for segment in workout.get("workoutSegments", []):
if not isinstance(segment, dict):
continue
segment_order = segment.get("segmentOrder", "?")
lines.append(f" Segment {segment_order}:")
_summarize_steps(lines, segment.get("workoutSteps", []), indent=" ")
if len(lines) == 1:
lines.append(" No workoutSegments/workoutSteps found.")
return "\n".join(lines)
def _summarize_steps(lines: list[str], steps: Any, indent: str) -> None:
if not isinstance(steps, list):
return
for step in steps:
if not isinstance(step, dict):
continue
step_type = _key(step.get("stepType")) or step.get("type") or "step"
if str(step.get("type")) == "RepeatGroupDTO" or step_type == "repeat":
iterations = step.get("numberOfIterations") or step.get("endConditionValue") or "?"
lines.append(f"{indent}repeat x{iterations}")
_summarize_steps(lines, step.get("workoutSteps", []), indent=f"{indent} ")
continue
end = _key(step.get("endCondition")) or "unknown-end"
end_value = step.get("endConditionValue")
target = _key(step.get("targetType")) or "no target"
lines.append(f"{indent}{step_type}: {end} {end_value}; target {target}")
def _key(value: Any) -> str | None:
if isinstance(value, dict):
for key in ("stepTypeKey", "conditionTypeKey", "workoutTargetTypeKey", "sportTypeKey"):
if value.get(key):
return str(value[key])
return None
def _steps_duration(steps: Any, multiplier: int) -> int:
if not isinstance(steps, list):
return 0
total = 0
for step in steps:
if not isinstance(step, dict):
continue
step_type = _key(step.get("stepType"))
if str(step.get("type")) == "RepeatGroupDTO" or step_type == "repeat":
iterations = int(step.get("numberOfIterations") or step.get("endConditionValue") or 1)
total += _steps_duration(step.get("workoutSteps", []), multiplier=iterations)
continue
end = _key(step.get("endCondition"))
if end == "time" and isinstance(step.get("endConditionValue"), int | float):
total += int(step["endConditionValue"]) * multiplier
return total
def _validate_steps(steps: list[Any], path: str, errors: list[str]) -> None:
for step_idx, step in enumerate(steps, start=1):
step_path = f"{path}[{step_idx}]"
if not isinstance(step, dict):
errors.append(f"{step_path} must be an object")
continue
step_type = _key(step.get("stepType"))
if step_type not in VALID_STEP_TYPES:
errors.append(f"{step_path}.stepType is invalid or missing: {step_type}")
continue
if step_type == "repeat" or str(step.get("type")) == "RepeatGroupDTO":
iterations = step.get("numberOfIterations") or step.get("endConditionValue")
if not isinstance(iterations, int | float) or iterations <= 0:
errors.append(f"{step_path}.numberOfIterations must be positive")
nested = step.get("workoutSteps")
if not isinstance(nested, list) or not nested:
errors.append(f"{step_path}.workoutSteps must be a non-empty list")
else:
_validate_steps(nested, f"{step_path}.workoutSteps", errors)
continue
end_condition = _key(step.get("endCondition"))
if end_condition is None:
errors.append(f"{step_path}.endCondition is missing")
elif end_condition != "lap.button":
end_value = step.get("endConditionValue")
if not isinstance(end_value, int | float) or end_value <= 0:
errors.append(f"{step_path}.endConditionValue must be positive")
if _key(step.get("targetType")) is None:
errors.append(f"{step_path}.targetType is missing")
def _strip_ids(value: Any) -> None:
if isinstance(value, dict):
for key in list(value.keys()):
if key in CLONE_ID_FIELDS:
value.pop(key, None)
else:
_strip_ids(value[key])
elif isinstance(value, list):
for item in value:
_strip_ids(item)
def _dedupe_calendar_entries(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str | int] = set()
deduped: list[dict[str, Any]] = []
for item in items:
marker: str | int = calendar_entry_id(item) or id(item)
if marker not in seen:
seen.add(marker)
deduped.append(item)
return deduped