intervalssync 0.9.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- intervalssync/__init__.py +3 -0
- intervalssync/_dropbox_app_key.py +7 -0
- intervalssync/_version.py +1 -0
- intervalssync/bryton/__init__.py +1 -0
- intervalssync/bryton/api.py +49 -0
- intervalssync/bryton/core.py +249 -0
- intervalssync/bryton/ddp.py +231 -0
- intervalssync/bryton/exceptions.py +13 -0
- intervalssync/bryton/fit_encode.py +363 -0
- intervalssync/bryton/workout.py +342 -0
- intervalssync/cli/__init__.py +5 -0
- intervalssync/cli/config.py +53 -0
- intervalssync/cli/env.py +189 -0
- intervalssync/cli/main.py +637 -0
- intervalssync/dropbox_client.py +143 -0
- intervalssync/gui/__init__.py +1 -0
- intervalssync/gui/app.py +352 -0
- intervalssync/gui/config.py +102 -0
- intervalssync/gui/profile_sync_ui.py +225 -0
- intervalssync/gui/secrets.py +135 -0
- intervalssync/gui/settings_view.py +716 -0
- intervalssync/gui/support_gamification.py +658 -0
- intervalssync/gui/sync_view.py +486 -0
- intervalssync/gui/system.py +21 -0
- intervalssync/gui/theme.py +216 -0
- intervalssync/gui/update_check.py +55 -0
- intervalssync/igpsport/__init__.py +1 -0
- intervalssync/igpsport/core.py +520 -0
- intervalssync/igpsport/interval_info.py +269 -0
- intervalssync/igpsport/profile_sync.py +391 -0
- intervalssync/igpsport/region.py +80 -0
- intervalssync/igpsport/workout.py +503 -0
- intervalssync/igpsport/zone_map.py +143 -0
- intervalssync/intervals_icu.py +201 -0
- intervalssync-0.9.3.dist-info/METADATA +179 -0
- intervalssync-0.9.3.dist-info/RECORD +39 -0
- intervalssync-0.9.3.dist-info/WHEEL +4 -0
- intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
- intervalssync-0.9.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""iGPSPORT regional endpoints (international vs China)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
IgpRegionName = Literal["international", "china"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class IgpRegionConfig:
|
|
13
|
+
name: IgpRegionName
|
|
14
|
+
login_url: str
|
|
15
|
+
activity_query_url: str
|
|
16
|
+
gateway_base: str
|
|
17
|
+
mobile_api_base: str
|
|
18
|
+
accept_language: str
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def workout_list_url(self) -> str:
|
|
22
|
+
return f"{self.mobile_api_base}/WorkOut/CustomWorkout"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def workout_edit_url(self) -> str:
|
|
26
|
+
return f"{self.mobile_api_base}/WorkOut/EditCustomWorkOut"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def get_interval_url(self) -> str:
|
|
30
|
+
return f"{self.mobile_api_base}/v2/User/UserIntervalInfo"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def update_interval_url(self) -> str:
|
|
34
|
+
return f"{self.mobile_api_base}/User/UpdatePersonalIntervalInfo"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def user_info_url(self) -> str:
|
|
38
|
+
return f"{self.mobile_api_base}/User/UserInfo"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def update_personal_user_info_url(self) -> str:
|
|
42
|
+
# App profile editor saves weight/height/etc here (not UpdateUserInfo).
|
|
43
|
+
return f"{self.mobile_api_base}/User/UpdatePersonalUserInfo"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
INTERNATIONAL = IgpRegionConfig(
|
|
47
|
+
name="international",
|
|
48
|
+
login_url="https://prod.en.igpsport.com/service/auth/account/login",
|
|
49
|
+
activity_query_url=(
|
|
50
|
+
"https://prod.en.igpsport.com/service/web-gateway/web-analyze/activity/queryMyActivity"
|
|
51
|
+
),
|
|
52
|
+
gateway_base="https://prod.en.igpsport.com/service/web-gateway/web-analyze/activity",
|
|
53
|
+
mobile_api_base="https://prod.en.igpsport.com/service/mobile/api",
|
|
54
|
+
accept_language="en",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
CHINA = IgpRegionConfig(
|
|
58
|
+
name="china",
|
|
59
|
+
login_url="https://prod.zh.igpsport.com/service/auth/account/login",
|
|
60
|
+
activity_query_url=(
|
|
61
|
+
"https://prod.zh.igpsport.com/service/web-gateway/web-analyze/activity/queryMyActivity"
|
|
62
|
+
),
|
|
63
|
+
gateway_base="https://prod.zh.igpsport.com/service/web-gateway/web-analyze/activity",
|
|
64
|
+
mobile_api_base="https://prod.zh.igpsport.com/service/mobile/api",
|
|
65
|
+
accept_language="zh-CN",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_REGIONS: dict[IgpRegionName, IgpRegionConfig] = {
|
|
69
|
+
"international": INTERNATIONAL,
|
|
70
|
+
"china": CHINA,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_region(name: str | None) -> IgpRegionConfig:
|
|
75
|
+
"""Return the region config for a name; default is international."""
|
|
76
|
+
if not name or name == "international":
|
|
77
|
+
return INTERNATIONAL
|
|
78
|
+
if name == "china":
|
|
79
|
+
return CHINA
|
|
80
|
+
raise ValueError(f"Unknown iGPSPORT region: {name!r} (expected 'international' or 'china')")
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Upload planned workouts from intervals.icu to iGPSPORT custom workouts.
|
|
2
|
+
|
|
3
|
+
Uses the mobile JSON API (EditCustomWorkOut), not FIT upload. Source data is
|
|
4
|
+
intervals.icu ``workout_doc`` from calendar events (see intervals.icu forum
|
|
5
|
+
guide on downloading planned workouts).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import date, timedelta
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .. import intervals_icu
|
|
18
|
+
from .core import SyncError, login
|
|
19
|
+
from .region import IgpRegionConfig, resolve_region
|
|
20
|
+
|
|
21
|
+
IGPS_API = "https://prod.en.igpsport.com"
|
|
22
|
+
IGPS_WORKOUT_LIST_URL = f"{IGPS_API}/service/mobile/api/WorkOut/CustomWorkout"
|
|
23
|
+
IGPS_WORKOUT_EDIT_URL = f"{IGPS_API}/service/mobile/api/WorkOut/EditCustomWorkOut"
|
|
24
|
+
|
|
25
|
+
# intervals.icu cycling types we accept in v1.
|
|
26
|
+
_CYCLING_TYPES = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"Ride",
|
|
29
|
+
"MountainBikeRide",
|
|
30
|
+
"GravelRide",
|
|
31
|
+
"VirtualRide",
|
|
32
|
+
"EBikeRide",
|
|
33
|
+
"EMountainBikeRide",
|
|
34
|
+
"TrackRide",
|
|
35
|
+
"Cyclocross",
|
|
36
|
+
"Handcycle",
|
|
37
|
+
"Velomobile",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
Progress = Callable[[str], None]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _noop(_message: str) -> None:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class WorkoutUploadResult:
|
|
50
|
+
listed: int = 0
|
|
51
|
+
uploaded: int = 0
|
|
52
|
+
skipped: int = 0
|
|
53
|
+
failed: int = 0
|
|
54
|
+
no_steps: int = 0
|
|
55
|
+
uploaded_map: dict[str, int] = field(default_factory=dict)
|
|
56
|
+
pruned_keys: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class WorkoutUploadConfig:
|
|
61
|
+
igp_user: str
|
|
62
|
+
igp_password: str
|
|
63
|
+
intervals_api_key: str
|
|
64
|
+
igp_region: str = "international"
|
|
65
|
+
oldest: date | None = None
|
|
66
|
+
newest: date | None = None
|
|
67
|
+
workout_days_ahead: int = 1
|
|
68
|
+
uploaded_workouts: dict[str, int] = field(default_factory=dict)
|
|
69
|
+
force_resync: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def list_custom_workouts(
|
|
73
|
+
session: requests.Session,
|
|
74
|
+
auth_headers: dict[str, str],
|
|
75
|
+
page_index: int = 1,
|
|
76
|
+
page_size: int = 20,
|
|
77
|
+
region: IgpRegionConfig | str | None = None,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Return the iGPSPORT custom-workout list response JSON."""
|
|
80
|
+
cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
|
|
81
|
+
resp = session.get(
|
|
82
|
+
cfg.workout_list_url,
|
|
83
|
+
params={"PageIndex": page_index, "PageSize": page_size},
|
|
84
|
+
headers=auth_headers,
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
return resp.json()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _workout_id_from_item(item: dict[str, Any]) -> int | None:
|
|
91
|
+
for key in ("workoutId", "id", "workout_id"):
|
|
92
|
+
value = item.get(key)
|
|
93
|
+
if value is not None:
|
|
94
|
+
try:
|
|
95
|
+
return int(value)
|
|
96
|
+
except (TypeError, ValueError):
|
|
97
|
+
continue
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fetch_all_custom_workout_ids(
|
|
102
|
+
session: requests.Session,
|
|
103
|
+
auth_headers: dict[str, str],
|
|
104
|
+
*,
|
|
105
|
+
page_size: int = 50,
|
|
106
|
+
region: IgpRegionConfig | str | None = None,
|
|
107
|
+
) -> set[int]:
|
|
108
|
+
"""Return every custom-workout ID currently on iGPSPORT."""
|
|
109
|
+
ids: set[int] = set()
|
|
110
|
+
page_index = 1
|
|
111
|
+
while True:
|
|
112
|
+
data = list_custom_workouts(
|
|
113
|
+
session, auth_headers, page_index, page_size, region=region
|
|
114
|
+
)
|
|
115
|
+
if data.get("code") != 0:
|
|
116
|
+
break
|
|
117
|
+
items = (data.get("data") or {}).get("items") or []
|
|
118
|
+
if not items:
|
|
119
|
+
break
|
|
120
|
+
for item in items:
|
|
121
|
+
if not isinstance(item, dict):
|
|
122
|
+
continue
|
|
123
|
+
workout_id = _workout_id_from_item(item)
|
|
124
|
+
if workout_id is not None:
|
|
125
|
+
ids.add(workout_id)
|
|
126
|
+
if len(items) < page_size:
|
|
127
|
+
break
|
|
128
|
+
page_index += 1
|
|
129
|
+
return ids
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def apply_uploaded_workout_map(
|
|
133
|
+
uploaded_workouts: dict[str, int],
|
|
134
|
+
result: WorkoutUploadResult,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Merge upload results into config and drop entries for deleted workouts."""
|
|
137
|
+
uploaded_workouts.update(result.uploaded_map)
|
|
138
|
+
for key in result.pruned_keys:
|
|
139
|
+
uploaded_workouts.pop(key, None)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def upload_custom_workout(
|
|
143
|
+
session: requests.Session,
|
|
144
|
+
auth_headers: dict[str, str],
|
|
145
|
+
body: dict[str, Any],
|
|
146
|
+
region: IgpRegionConfig | str | None = None,
|
|
147
|
+
) -> int | None:
|
|
148
|
+
"""Create or update a custom workout; return workoutId on success."""
|
|
149
|
+
cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
|
|
150
|
+
resp = session.post(cfg.workout_edit_url, json=body, headers=auth_headers)
|
|
151
|
+
if not resp.ok:
|
|
152
|
+
return None
|
|
153
|
+
data = resp.json()
|
|
154
|
+
if data.get("code") != 0:
|
|
155
|
+
return None
|
|
156
|
+
payload = data.get("data") or {}
|
|
157
|
+
workout_id = payload.get("workoutId")
|
|
158
|
+
return int(workout_id) if workout_id is not None else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _step_name(step: dict[str, Any], index: int) -> str:
|
|
162
|
+
text = str(step.get("text") or "").strip()
|
|
163
|
+
if text:
|
|
164
|
+
return text[:64]
|
|
165
|
+
return f"Step {index}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _intensity_class(step: dict[str, Any]) -> str:
|
|
169
|
+
intensity = str(step.get("intensity") or "").lower()
|
|
170
|
+
if step.get("warmup") or intensity == "warmup":
|
|
171
|
+
return "WarmUp"
|
|
172
|
+
if step.get("cooldown") or intensity == "cooldown":
|
|
173
|
+
return "CoolDown"
|
|
174
|
+
if intensity in ("rest", "recovery"):
|
|
175
|
+
return "Rest"
|
|
176
|
+
return "Active"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _num(value: Any) -> float | None:
|
|
180
|
+
if value is None:
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
return float(value)
|
|
184
|
+
except (TypeError, ValueError):
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _map_power_target(step: dict[str, Any]) -> dict[str, Any] | None:
|
|
189
|
+
resolved = step.get("_power")
|
|
190
|
+
if isinstance(resolved, dict):
|
|
191
|
+
start = _num(resolved.get("start"))
|
|
192
|
+
end = _num(resolved.get("end"))
|
|
193
|
+
value = _num(resolved.get("value"))
|
|
194
|
+
min_v = start if start is not None else value
|
|
195
|
+
max_v = end if end is not None else value
|
|
196
|
+
if min_v is None and max_v is None:
|
|
197
|
+
return None
|
|
198
|
+
min_v = int(min_v if min_v is not None else max_v)
|
|
199
|
+
max_v = int(max_v if max_v is not None else min_v)
|
|
200
|
+
if min_v > max_v:
|
|
201
|
+
min_v, max_v = max_v, min_v
|
|
202
|
+
return {
|
|
203
|
+
"unit": "PowerCustom",
|
|
204
|
+
"value": 0,
|
|
205
|
+
"minValue": min_v,
|
|
206
|
+
"maxValue": max_v,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
power = step.get("power")
|
|
210
|
+
if not isinstance(power, dict):
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
units = str(power.get("units") or "").lower()
|
|
214
|
+
start = _num(power.get("start"))
|
|
215
|
+
end = _num(power.get("end"))
|
|
216
|
+
value = _num(power.get("value"))
|
|
217
|
+
min_v = start if start is not None else value
|
|
218
|
+
max_v = end if end is not None else value
|
|
219
|
+
if min_v is None and max_v is None:
|
|
220
|
+
return None
|
|
221
|
+
min_v = int(min_v if min_v is not None else max_v)
|
|
222
|
+
max_v = int(max_v if max_v is not None else min_v)
|
|
223
|
+
if min_v > max_v:
|
|
224
|
+
min_v, max_v = max_v, min_v
|
|
225
|
+
|
|
226
|
+
if units == "%ftp":
|
|
227
|
+
return {
|
|
228
|
+
"unit": "PercentOfFTP",
|
|
229
|
+
"minValue": min_v,
|
|
230
|
+
"maxValue": max_v,
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
"unit": "PowerCustom",
|
|
234
|
+
"value": 0,
|
|
235
|
+
"minValue": min_v,
|
|
236
|
+
"maxValue": max_v,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _map_hr_target(step: dict[str, Any]) -> dict[str, Any] | None:
|
|
241
|
+
resolved = step.get("_hr")
|
|
242
|
+
if isinstance(resolved, dict):
|
|
243
|
+
start = _num(resolved.get("start"))
|
|
244
|
+
end = _num(resolved.get("end"))
|
|
245
|
+
value = _num(resolved.get("value"))
|
|
246
|
+
min_v = start if start is not None else value
|
|
247
|
+
max_v = end if end is not None else value
|
|
248
|
+
if min_v is not None and max_v is not None:
|
|
249
|
+
return {
|
|
250
|
+
"unit": "HeartRateCustom",
|
|
251
|
+
"minValue": int(min_v),
|
|
252
|
+
"maxValue": int(max_v),
|
|
253
|
+
}
|
|
254
|
+
if value is not None:
|
|
255
|
+
if 1 <= value <= 5 and value == int(value):
|
|
256
|
+
return {"unit": "HeartRate", "value": int(value)}
|
|
257
|
+
return {
|
|
258
|
+
"unit": "HeartRateCustom",
|
|
259
|
+
"minValue": int(value),
|
|
260
|
+
"maxValue": int(value),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
hr = step.get("hr")
|
|
264
|
+
if not isinstance(hr, dict):
|
|
265
|
+
return None
|
|
266
|
+
value = _num(hr.get("value"))
|
|
267
|
+
start = _num(hr.get("start"))
|
|
268
|
+
end = _num(hr.get("end"))
|
|
269
|
+
if start is not None and end is not None:
|
|
270
|
+
return {
|
|
271
|
+
"unit": "HeartRateCustom",
|
|
272
|
+
"minValue": int(start),
|
|
273
|
+
"maxValue": int(end),
|
|
274
|
+
}
|
|
275
|
+
if value is not None:
|
|
276
|
+
if 1 <= value <= 5 and value == int(value):
|
|
277
|
+
return {"unit": "HeartRate", "value": int(value)}
|
|
278
|
+
return {
|
|
279
|
+
"unit": "HeartRateCustom",
|
|
280
|
+
"minValue": int(value),
|
|
281
|
+
"maxValue": int(value),
|
|
282
|
+
}
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _map_cadence_target(step: dict[str, Any]) -> dict[str, Any] | None:
|
|
287
|
+
cadence = step.get("cadence")
|
|
288
|
+
if not isinstance(cadence, dict):
|
|
289
|
+
return None
|
|
290
|
+
value = _num(cadence.get("value"))
|
|
291
|
+
start = _num(cadence.get("start"))
|
|
292
|
+
end = _num(cadence.get("end"))
|
|
293
|
+
min_v = start if start is not None else value
|
|
294
|
+
max_v = end if end is not None else value
|
|
295
|
+
if min_v is None and max_v is None:
|
|
296
|
+
return None
|
|
297
|
+
min_v = int(min_v if min_v is not None else max_v)
|
|
298
|
+
max_v = int(max_v if max_v is not None else min_v)
|
|
299
|
+
return {"unit": "Cadence", "minValue": min_v, "maxValue": max_v}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _map_icu_step(step: dict[str, Any], index: int) -> dict[str, Any] | None:
|
|
303
|
+
reps = step.get("reps")
|
|
304
|
+
nested = step.get("steps")
|
|
305
|
+
if reps and isinstance(nested, list) and nested:
|
|
306
|
+
child_steps = []
|
|
307
|
+
for i, child in enumerate(nested, start=1):
|
|
308
|
+
if not isinstance(child, dict):
|
|
309
|
+
continue
|
|
310
|
+
mapped = _map_icu_step(child, i)
|
|
311
|
+
if mapped is not None:
|
|
312
|
+
child_steps.append(mapped)
|
|
313
|
+
if not child_steps:
|
|
314
|
+
return None
|
|
315
|
+
return {
|
|
316
|
+
"type": "Repetition",
|
|
317
|
+
"name": _step_name(step, index),
|
|
318
|
+
"uuid": str(uuid.uuid4()),
|
|
319
|
+
"intensityClass": "Active",
|
|
320
|
+
"openDuration": "false",
|
|
321
|
+
"length": {"unit": "Repetition", "value": int(reps)},
|
|
322
|
+
"steps": child_steps,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
open_duration = bool(step.get("until_lap_press"))
|
|
326
|
+
igp_step: dict[str, Any] = {
|
|
327
|
+
"type": "Step",
|
|
328
|
+
"name": _step_name(step, index),
|
|
329
|
+
"uuid": str(uuid.uuid4()),
|
|
330
|
+
"intensityClass": _intensity_class(step),
|
|
331
|
+
"openDuration": "true" if open_duration else "false",
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if not open_duration:
|
|
335
|
+
duration = step.get("duration")
|
|
336
|
+
if duration is None:
|
|
337
|
+
return None
|
|
338
|
+
igp_step["length"] = {"unit": "Second", "value": int(duration)}
|
|
339
|
+
|
|
340
|
+
power_target = _map_power_target(step)
|
|
341
|
+
if power_target:
|
|
342
|
+
igp_step["intensityTarget"] = power_target
|
|
343
|
+
else:
|
|
344
|
+
hr_target = _map_hr_target(step)
|
|
345
|
+
if hr_target:
|
|
346
|
+
igp_step["intensityTarget"] = hr_target
|
|
347
|
+
|
|
348
|
+
cadence_target = _map_cadence_target(step)
|
|
349
|
+
if cadence_target:
|
|
350
|
+
igp_step["cadenceTarget"] = cadence_target
|
|
351
|
+
|
|
352
|
+
return igp_step
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _total_time(structure: list[dict[str, Any]], workout_doc: dict[str, Any]) -> int:
|
|
356
|
+
doc_duration = workout_doc.get("duration")
|
|
357
|
+
if isinstance(doc_duration, int) and doc_duration > 0:
|
|
358
|
+
return doc_duration
|
|
359
|
+
if isinstance(doc_duration, float) and doc_duration > 0:
|
|
360
|
+
return int(doc_duration)
|
|
361
|
+
|
|
362
|
+
total = 0
|
|
363
|
+
|
|
364
|
+
def walk(steps: list[dict[str, Any]], repeat: int = 1) -> None:
|
|
365
|
+
nonlocal total
|
|
366
|
+
for step in steps:
|
|
367
|
+
if step.get("type") == "Repetition":
|
|
368
|
+
reps = step.get("length", {}).get("value", 1)
|
|
369
|
+
nested = step.get("steps") or []
|
|
370
|
+
walk(nested, repeat * int(reps))
|
|
371
|
+
elif step.get("openDuration") != "true":
|
|
372
|
+
length = step.get("length") or {}
|
|
373
|
+
if length.get("unit") == "Second":
|
|
374
|
+
total += int(length.get("value", 0)) * repeat
|
|
375
|
+
|
|
376
|
+
walk(structure)
|
|
377
|
+
return total
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def icu_workout_doc_to_igps(
|
|
381
|
+
name: str,
|
|
382
|
+
description: str,
|
|
383
|
+
workout_doc: dict[str, Any],
|
|
384
|
+
*,
|
|
385
|
+
existing_workout_id: int | None = None,
|
|
386
|
+
) -> dict[str, Any] | None:
|
|
387
|
+
"""Map intervals.icu workout_doc to an EditCustomWorkOut request body."""
|
|
388
|
+
raw_steps = workout_doc.get("steps")
|
|
389
|
+
if not isinstance(raw_steps, list) or not raw_steps:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
structure: list[dict[str, Any]] = []
|
|
393
|
+
for i, step in enumerate(raw_steps, start=1):
|
|
394
|
+
if not isinstance(step, dict):
|
|
395
|
+
continue
|
|
396
|
+
mapped = _map_icu_step(step, i)
|
|
397
|
+
if mapped is not None:
|
|
398
|
+
structure.append(mapped)
|
|
399
|
+
|
|
400
|
+
if not structure:
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
doc_description = str(workout_doc.get("description") or description or "")
|
|
404
|
+
data: dict[str, Any] = {
|
|
405
|
+
"title": name[:64],
|
|
406
|
+
"description": doc_description[:500],
|
|
407
|
+
"totalTime": _total_time(structure, workout_doc),
|
|
408
|
+
"workoutType": "bike",
|
|
409
|
+
"sportBigType": 1,
|
|
410
|
+
"allowDeletion": True,
|
|
411
|
+
"structure": structure,
|
|
412
|
+
}
|
|
413
|
+
if existing_workout_id:
|
|
414
|
+
data["id"] = str(existing_workout_id)
|
|
415
|
+
|
|
416
|
+
return {"data": data}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def upload_workouts(
|
|
420
|
+
config: WorkoutUploadConfig,
|
|
421
|
+
progress: Progress | None = None,
|
|
422
|
+
) -> WorkoutUploadResult:
|
|
423
|
+
"""Fetch upcoming intervals.icu workouts and push them to iGPSPORT."""
|
|
424
|
+
report = progress or _noop
|
|
425
|
+
result = WorkoutUploadResult()
|
|
426
|
+
|
|
427
|
+
today = date.today()
|
|
428
|
+
oldest = config.oldest or today
|
|
429
|
+
days = max(1, config.workout_days_ahead)
|
|
430
|
+
newest = config.newest or (today + timedelta(days=days - 1))
|
|
431
|
+
|
|
432
|
+
report("Logging in to iGPSPORT…")
|
|
433
|
+
session = requests.Session()
|
|
434
|
+
region = resolve_region(config.igp_region)
|
|
435
|
+
auth_headers = login(session, config.igp_user, config.igp_password, region)
|
|
436
|
+
report("Logged in.")
|
|
437
|
+
|
|
438
|
+
live_ids = fetch_all_custom_workout_ids(session, auth_headers, region=region)
|
|
439
|
+
report(f"Found {len(live_ids)} custom workouts on iGPSPORT.")
|
|
440
|
+
|
|
441
|
+
report("Fetching planned workouts from intervals.icu…")
|
|
442
|
+
try:
|
|
443
|
+
calendar = intervals_icu.fetch_calendar_workouts(
|
|
444
|
+
config.intervals_api_key, oldest, newest
|
|
445
|
+
)
|
|
446
|
+
except requests.RequestException as exc:
|
|
447
|
+
raise SyncError(f"Could not fetch intervals.icu workouts: {exc}") from exc
|
|
448
|
+
|
|
449
|
+
result.listed = len(calendar)
|
|
450
|
+
report(f"Found {len(calendar)} planned workouts.")
|
|
451
|
+
|
|
452
|
+
for workout in calendar:
|
|
453
|
+
event_key = str(workout.event_id)
|
|
454
|
+
|
|
455
|
+
if workout.activity_type not in _CYCLING_TYPES:
|
|
456
|
+
report(
|
|
457
|
+
f"↷ Skipping {workout.name} — unsupported type "
|
|
458
|
+
f"{workout.activity_type!r} (cycling only in v1)."
|
|
459
|
+
)
|
|
460
|
+
result.skipped += 1
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
stored_id = config.uploaded_workouts.get(event_key)
|
|
464
|
+
on_igpsport = stored_id is not None and stored_id in live_ids
|
|
465
|
+
|
|
466
|
+
if on_igpsport and not config.force_resync:
|
|
467
|
+
report(f"↷ Skipping {workout.name} — already on iGPSPORT.")
|
|
468
|
+
result.skipped += 1
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
update_id = stored_id if config.force_resync and on_igpsport else None
|
|
472
|
+
body = icu_workout_doc_to_igps(
|
|
473
|
+
workout.name,
|
|
474
|
+
workout.description,
|
|
475
|
+
workout.workout_doc,
|
|
476
|
+
existing_workout_id=update_id,
|
|
477
|
+
)
|
|
478
|
+
if body is None:
|
|
479
|
+
report(
|
|
480
|
+
f"⚠ Skipping {workout.name} — no structured steps "
|
|
481
|
+
"(open the workout in intervals.icu first)."
|
|
482
|
+
)
|
|
483
|
+
result.no_steps += 1
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
report(f"Uploading {workout.name}…")
|
|
487
|
+
workout_id = upload_custom_workout(session, auth_headers, body, region=region)
|
|
488
|
+
if workout_id:
|
|
489
|
+
report(f"✓ Uploaded {workout.name} (workoutId {workout_id})")
|
|
490
|
+
result.uploaded += 1
|
|
491
|
+
result.uploaded_map[event_key] = workout_id
|
|
492
|
+
live_ids.add(workout_id)
|
|
493
|
+
else:
|
|
494
|
+
report(f"✗ Failed to upload {workout.name}.")
|
|
495
|
+
result.failed += 1
|
|
496
|
+
|
|
497
|
+
for event_key, workout_id in config.uploaded_workouts.items():
|
|
498
|
+
if event_key in result.uploaded_map:
|
|
499
|
+
continue
|
|
500
|
+
if workout_id not in live_ids:
|
|
501
|
+
result.pruned_keys.append(event_key)
|
|
502
|
+
|
|
503
|
+
return result
|