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.
Files changed (39) hide show
  1. intervalssync/__init__.py +3 -0
  2. intervalssync/_dropbox_app_key.py +7 -0
  3. intervalssync/_version.py +1 -0
  4. intervalssync/bryton/__init__.py +1 -0
  5. intervalssync/bryton/api.py +49 -0
  6. intervalssync/bryton/core.py +249 -0
  7. intervalssync/bryton/ddp.py +231 -0
  8. intervalssync/bryton/exceptions.py +13 -0
  9. intervalssync/bryton/fit_encode.py +363 -0
  10. intervalssync/bryton/workout.py +342 -0
  11. intervalssync/cli/__init__.py +5 -0
  12. intervalssync/cli/config.py +53 -0
  13. intervalssync/cli/env.py +189 -0
  14. intervalssync/cli/main.py +637 -0
  15. intervalssync/dropbox_client.py +143 -0
  16. intervalssync/gui/__init__.py +1 -0
  17. intervalssync/gui/app.py +352 -0
  18. intervalssync/gui/config.py +102 -0
  19. intervalssync/gui/profile_sync_ui.py +225 -0
  20. intervalssync/gui/secrets.py +135 -0
  21. intervalssync/gui/settings_view.py +716 -0
  22. intervalssync/gui/support_gamification.py +658 -0
  23. intervalssync/gui/sync_view.py +486 -0
  24. intervalssync/gui/system.py +21 -0
  25. intervalssync/gui/theme.py +216 -0
  26. intervalssync/gui/update_check.py +55 -0
  27. intervalssync/igpsport/__init__.py +1 -0
  28. intervalssync/igpsport/core.py +520 -0
  29. intervalssync/igpsport/interval_info.py +269 -0
  30. intervalssync/igpsport/profile_sync.py +391 -0
  31. intervalssync/igpsport/region.py +80 -0
  32. intervalssync/igpsport/workout.py +503 -0
  33. intervalssync/igpsport/zone_map.py +143 -0
  34. intervalssync/intervals_icu.py +201 -0
  35. intervalssync-0.9.3.dist-info/METADATA +179 -0
  36. intervalssync-0.9.3.dist-info/RECORD +39 -0
  37. intervalssync-0.9.3.dist-info/WHEEL +4 -0
  38. intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
  39. 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