145 lines
5.0 KiB
Python
Executable File
145 lines
5.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
from garmin_coach_clone.auth import login
|
|
from garmin_coach_clone.coach import (
|
|
best_workout_for_date,
|
|
extract_training_plans,
|
|
get_fbt_adaptive_workout,
|
|
is_adaptive_plan,
|
|
plan_id,
|
|
task_workout_for_date,
|
|
)
|
|
from garmin_coach_clone.dates import parse_date
|
|
from garmin_coach_clone.io import dump_redacted_json, print_json
|
|
from garmin_coach_clone.workouts import (
|
|
clone_workout_payload,
|
|
existing_clone_names,
|
|
summarize_workout,
|
|
validate_workout_payload,
|
|
)
|
|
|
|
load_dotenv()
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Clone a Garmin Coach workout if step data is exposed."
|
|
)
|
|
parser.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
|
|
parser.add_argument("--dry-run", action="store_true", help="Convert and print JSON only.")
|
|
parser.add_argument("--schedule", action="store_true", help="Upload and schedule the clone.")
|
|
parser.add_argument(
|
|
"--force", action="store_true", help="Create even if a clone already exists."
|
|
)
|
|
parser.add_argument(
|
|
"--dump-json", action="store_true", help="Dump redacted Coach/adaptive JSON."
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
target_date = parse_date(args.date)
|
|
prefix = os.getenv("CLONE_PREFIX", "GCClone")
|
|
api = login()
|
|
|
|
plans = extract_training_plans(api.get_training_plans())
|
|
candidates = [plan for plan in plans if is_adaptive_plan(plan)]
|
|
if not candidates:
|
|
print("No Garmin Coach/adaptive plan was identifiable. Cannot clone.")
|
|
return 1
|
|
|
|
source = None
|
|
source_plan_name = None
|
|
for plan in candidates:
|
|
pid = plan_id(plan)
|
|
if pid is None:
|
|
continue
|
|
detail = (
|
|
api.get_adaptive_training_plan_by_id(pid)
|
|
if str(plan.get("trainingPlanCategory", "")).upper() == "FBT_ADAPTIVE"
|
|
else api.get_training_plan_by_id(pid)
|
|
)
|
|
if args.dump_json:
|
|
out = Path("debug") / f"coach_clone_source_{pid}_{target_date.isoformat()}.json"
|
|
dump_redacted_json(out, detail)
|
|
print(f"Redacted source JSON written to {out}")
|
|
source = best_workout_for_date(detail, target_date)
|
|
if source is None:
|
|
task_workout = task_workout_for_date(detail, target_date)
|
|
workout_uuid = task_workout.get("workoutUuid") if task_workout else None
|
|
if workout_uuid:
|
|
source = get_fbt_adaptive_workout(api, str(workout_uuid))
|
|
if source is not None:
|
|
source_plan_name = plan.get("name")
|
|
break
|
|
|
|
if source is None:
|
|
print(
|
|
"Coach/adaptive plan data was fetched, but no object with workoutSegments "
|
|
f"could be matched to {target_date.isoformat()}."
|
|
)
|
|
print(
|
|
"This means the missing piece is Coach step extraction, "
|
|
"not normal workout scheduling."
|
|
)
|
|
return 1
|
|
|
|
payload = clone_workout_payload(source, target_date, prefix)
|
|
print(f"Source plan: {source_plan_name}")
|
|
print(summarize_workout(payload))
|
|
validation_errors = validate_workout_payload(payload)
|
|
if validation_errors:
|
|
print("\nConverted clone payload failed local validation. Nothing was uploaded.")
|
|
for error in validation_errors:
|
|
print(f" - {error}")
|
|
return 1
|
|
print("\nConverted clone payload passed local validation.")
|
|
|
|
existing = existing_clone_names(api.get_workouts(limit=100), target_date, prefix)
|
|
if existing and not args.force:
|
|
print("A clone already exists for this date; skipping upload:")
|
|
for name in existing:
|
|
print(f" - {name}")
|
|
return 0
|
|
|
|
if args.dry_run or not args.schedule:
|
|
print("\nDry run: converted clone JSON follows. Nothing was uploaded.")
|
|
print_json(payload)
|
|
if not args.schedule:
|
|
print("\nPass --schedule to upload and schedule this clone.")
|
|
return 0
|
|
|
|
result = api.upload_workout(payload)
|
|
workout_id = result.get("workoutId") or result.get("id")
|
|
if workout_id is None:
|
|
print("Upload returned no workoutId. Raw response:")
|
|
print_json(result)
|
|
return 1
|
|
|
|
schedule_result = api.schedule_workout(workout_id, target_date.isoformat())
|
|
print(f"Uploaded clone workout ID: {workout_id}")
|
|
print("Schedule response:")
|
|
print_json(schedule_result)
|
|
print(
|
|
f"""
|
|
Next checks on the Edge 1030 after Garmin Connect sync:
|
|
1. Check Training > Workouts for "{payload["workoutName"]}".
|
|
2. Check Training > Training Plan > calendar icon > {target_date.isoformat()}.
|
|
3. Start it with a normal route/course active and verify navigation still works.
|
|
4. After completing, check whether Garmin Coach marks anything complete; this is not expected yet.
|
|
"""
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|