feat: smart recreate and dedupe
This commit is contained in:
@ -272,6 +272,7 @@ class SyncService:
|
|||||||
elif action == "recreate_missing":
|
elif action == "recreate_missing":
|
||||||
for scheduled_id in stale_scheduled_ids:
|
for scheduled_id in stale_scheduled_ids:
|
||||||
client.unschedule_workout(scheduled_id)
|
client.unschedule_workout(scheduled_id)
|
||||||
|
self._delete_date_marker_workouts(client, target_date)
|
||||||
|
|
||||||
upload_result = client.upload_workout(payload)
|
upload_result = client.upload_workout(payload)
|
||||||
workout_id = upload_result.get("workoutId") or upload_result.get("id")
|
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"):
|
if self.settings.delete_old_clones and mapping.get("clone_workout_id"):
|
||||||
client.delete_workout(str(mapping["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(
|
def _find_scheduled_ids(
|
||||||
self, client: Any, clone_name: str, target_date: date
|
self, client: Any, clone_name: str, target_date: date
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
|||||||
@ -382,9 +382,53 @@ def _strip_ids(value: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _normalized_steps(workout: dict[str, Any]) -> Any:
|
def _normalized_steps(workout: dict[str, Any]) -> Any:
|
||||||
value = deepcopy(workout.get("workoutSegments"))
|
segments = workout.get("workoutSegments")
|
||||||
_strip_ids(value)
|
if not isinstance(segments, list):
|
||||||
return value
|
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]]:
|
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 result["created"] == 1
|
||||||
assert garmin.client.unscheduled == ["s1"]
|
assert garmin.client.unscheduled == ["s1"]
|
||||||
|
assert garmin.client.deleted == ["w-old"]
|
||||||
assert garmin.client.scheduled == [("w1", "2026-06-16")]
|
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.uploads
|
||||||
assert garmin.client.scheduled == [("w1", "2026-06-16")]
|
assert garmin.client.scheduled == [("w1", "2026-06-16")]
|
||||||
assert garmin.client.unscheduled == []
|
assert garmin.client.unscheduled == []
|
||||||
|
assert garmin.client.deleted == ["w-old"]
|
||||||
mapping = repo.get_clone_mapping("2026-06-16")
|
mapping = repo.get_clone_mapping("2026-06-16")
|
||||||
assert mapping is not None
|
assert mapping is not None
|
||||||
assert mapping["clone_workout_id"] == "w1"
|
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)
|
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:
|
def test_existing_clone_names_filters_by_prefix_and_date() -> None:
|
||||||
names = existing_clone_names(
|
names = existing_clone_names(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user