feat: smart recreate and dedupe
This commit is contained in:
@ -272,6 +272,7 @@ class SyncService:
|
||||
elif action == "recreate_missing":
|
||||
for scheduled_id in stale_scheduled_ids:
|
||||
client.unschedule_workout(scheduled_id)
|
||||
self._delete_date_marker_workouts(client, target_date)
|
||||
|
||||
upload_result = client.upload_workout(payload)
|
||||
workout_id = upload_result.get("workoutId") or upload_result.get("id")
|
||||
@ -388,6 +389,18 @@ class SyncService:
|
||||
if self.settings.delete_old_clones and mapping.get("clone_workout_id"):
|
||||
client.delete_workout(str(mapping["clone_workout_id"]))
|
||||
|
||||
def _delete_date_marker_workouts(self, client: Any, target_date: date) -> list[str]:
|
||||
deleted: list[str] = []
|
||||
for workout in existing_clone_workouts(
|
||||
client.get_workouts(limit=100), target_date, self.settings.clone_prefix
|
||||
):
|
||||
workout_id = _workout_id(workout)
|
||||
if workout_id is None:
|
||||
continue
|
||||
client.delete_workout(workout_id)
|
||||
deleted.append(workout_id)
|
||||
return deleted
|
||||
|
||||
def _find_scheduled_ids(
|
||||
self, client: Any, clone_name: str, target_date: date
|
||||
) -> list[str]:
|
||||
|
||||
@ -382,9 +382,53 @@ def _strip_ids(value: Any) -> None:
|
||||
|
||||
|
||||
def _normalized_steps(workout: dict[str, Any]) -> Any:
|
||||
value = deepcopy(workout.get("workoutSegments"))
|
||||
_strip_ids(value)
|
||||
return value
|
||||
segments = workout.get("workoutSegments")
|
||||
if not isinstance(segments, list):
|
||||
return None
|
||||
return [
|
||||
_step_list_signature(segment.get("workoutSteps"))
|
||||
for segment in segments
|
||||
if isinstance(segment, dict)
|
||||
]
|
||||
|
||||
|
||||
def _step_list_signature(steps: Any) -> list[Any]:
|
||||
if not isinstance(steps, list):
|
||||
return []
|
||||
return [_step_signature(step) for step in steps if isinstance(step, dict)]
|
||||
|
||||
|
||||
def _step_signature(step: dict[str, Any]) -> dict[str, Any]:
|
||||
step_type = _key(step.get("stepType")) or str(step.get("type") or "")
|
||||
signature: dict[str, Any] = {
|
||||
"step_type": step_type,
|
||||
"end_condition": _key(step.get("endCondition")),
|
||||
"end_value": _number(step.get("endConditionValue")),
|
||||
}
|
||||
if str(step.get("type")) == "RepeatGroupDTO" or step_type == "repeat":
|
||||
signature["iterations"] = _number(
|
||||
step.get("numberOfIterations") or step.get("endConditionValue")
|
||||
)
|
||||
signature["steps"] = _step_list_signature(step.get("workoutSteps"))
|
||||
return signature
|
||||
|
||||
signature.update(
|
||||
{
|
||||
"target_type": _key(step.get("targetType")),
|
||||
"target_one": _number(step.get("targetValueOne")),
|
||||
"target_two": _number(step.get("targetValueTwo")),
|
||||
"target_unit": step.get("targetValueUnit"),
|
||||
"secondary_target_type": _key(step.get("secondaryTargetType")),
|
||||
"secondary_target_one": _number(step.get("secondaryTargetValueOne")),
|
||||
"secondary_target_two": _number(step.get("secondaryTargetValueTwo")),
|
||||
"secondary_target_unit": step.get("secondaryTargetValueUnit"),
|
||||
}
|
||||
)
|
||||
return signature
|
||||
|
||||
|
||||
def _number(value: Any) -> Any:
|
||||
return float(value) if isinstance(value, int | float) else value
|
||||
|
||||
|
||||
def _dedupe_calendar_entries(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
@ -216,6 +216,7 @@ def test_sync_recreates_date_name_match_with_different_steps(tmp_path, monkeypat
|
||||
|
||||
assert result["created"] == 1
|
||||
assert garmin.client.unscheduled == ["s1"]
|
||||
assert garmin.client.deleted == ["w-old"]
|
||||
assert garmin.client.scheduled == [("w1", "2026-06-16")]
|
||||
|
||||
|
||||
@ -295,6 +296,7 @@ def test_current_mapping_recreates_when_calendar_entry_is_missing(
|
||||
assert garmin.client.uploads
|
||||
assert garmin.client.scheduled == [("w1", "2026-06-16")]
|
||||
assert garmin.client.unscheduled == []
|
||||
assert garmin.client.deleted == ["w-old"]
|
||||
mapping = repo.get_clone_mapping("2026-06-16")
|
||||
assert mapping is not None
|
||||
assert mapping["clone_workout_id"] == "w1"
|
||||
|
||||
@ -60,6 +60,44 @@ def test_workout_steps_equal_ignores_ids_but_compares_steps() -> None:
|
||||
assert not workout_steps_equal(left, right)
|
||||
|
||||
|
||||
def test_workout_steps_equal_ignores_garmin_upload_defaults() -> None:
|
||||
left = build_dummy_cycling_workout("Left")
|
||||
right = build_dummy_cycling_workout("Right")
|
||||
repeat = right["workoutSegments"][0]["workoutSteps"][1]
|
||||
repeat["stepOrder"] = 2
|
||||
repeat["childStepId"] = 1
|
||||
repeat["smartRepeat"] = False
|
||||
for idx, step in enumerate(right["workoutSegments"][0]["workoutSteps"], start=1):
|
||||
step["stepId"] = 1000 + idx
|
||||
step["stepOrder"] = idx
|
||||
step["equipmentType"] = {
|
||||
"displayOrder": 0,
|
||||
"equipmentTypeId": 0,
|
||||
"equipmentTypeKey": None,
|
||||
}
|
||||
step["strokeType"] = {
|
||||
"displayOrder": 0,
|
||||
"strokeTypeId": 0,
|
||||
"strokeTypeKey": None,
|
||||
}
|
||||
for idx, step in enumerate(repeat["workoutSteps"], start=3):
|
||||
step["childStepId"] = 1
|
||||
step["stepId"] = 1000 + idx
|
||||
step["stepOrder"] = idx
|
||||
step["equipmentType"] = {
|
||||
"displayOrder": 0,
|
||||
"equipmentTypeId": 0,
|
||||
"equipmentTypeKey": None,
|
||||
}
|
||||
step["strokeType"] = {
|
||||
"displayOrder": 0,
|
||||
"strokeTypeId": 0,
|
||||
"strokeTypeKey": None,
|
||||
}
|
||||
|
||||
assert workout_steps_equal(left, right)
|
||||
|
||||
|
||||
def test_existing_clone_names_filters_by_prefix_and_date() -> None:
|
||||
names = existing_clone_names(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user