intervalssync 0.9.3__tar.gz → 0.9.4__tar.gz

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 (79) hide show
  1. {intervalssync-0.9.3 → intervalssync-0.9.4}/PKG-INFO +1 -1
  2. {intervalssync-0.9.3 → intervalssync-0.9.4}/pyproject.toml +1 -1
  3. intervalssync-0.9.4/src/intervalssync/_version.py +1 -0
  4. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/core.py +3 -2
  5. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/fit_encode.py +23 -0
  6. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/workout.py +60 -4
  7. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/cli/config.py +2 -0
  8. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/cli/main.py +16 -1
  9. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/config.py +2 -0
  10. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/sync_view.py +19 -2
  11. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/core.py +97 -26
  12. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/intervals_icu.py +92 -13
  13. intervalssync-0.9.4/tests/test_activity_checkpoints.py +87 -0
  14. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_bryton_core.py +7 -2
  15. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_bryton_workout.py +176 -2
  16. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_cli.py +93 -7
  17. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_config.py +3 -0
  18. intervalssync-0.9.4/tests/test_gui_sync.py +84 -0
  19. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_igpsport_core.py +551 -18
  20. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_intervals_icu.py +4 -0
  21. intervalssync-0.9.3/src/intervalssync/_version.py +0 -1
  22. {intervalssync-0.9.3 → intervalssync-0.9.4}/.env.example +0 -0
  23. {intervalssync-0.9.3 → intervalssync-0.9.4}/.github/FUNDING.yml +0 -0
  24. {intervalssync-0.9.3 → intervalssync-0.9.4}/.github/pull_request_template.md +0 -0
  25. {intervalssync-0.9.3 → intervalssync-0.9.4}/.github/workflows/release.yml +0 -0
  26. {intervalssync-0.9.3 → intervalssync-0.9.4}/.github/workflows/test.yml +0 -0
  27. {intervalssync-0.9.3 → intervalssync-0.9.4}/.gitignore +0 -0
  28. {intervalssync-0.9.3 → intervalssync-0.9.4}/.python-version +0 -0
  29. {intervalssync-0.9.3 → intervalssync-0.9.4}/CLAUDE.md +0 -0
  30. {intervalssync-0.9.3 → intervalssync-0.9.4}/CONTRIBUTING.md +0 -0
  31. {intervalssync-0.9.3 → intervalssync-0.9.4}/LICENSE +0 -0
  32. {intervalssync-0.9.3 → intervalssync-0.9.4}/README.md +0 -0
  33. {intervalssync-0.9.3 → intervalssync-0.9.4}/assets/icon.png +0 -0
  34. {intervalssync-0.9.3 → intervalssync-0.9.4}/docs/AGENT.md +0 -0
  35. {intervalssync-0.9.3 → intervalssync-0.9.4}/docs/img/demo.gif +0 -0
  36. {intervalssync-0.9.3 → intervalssync-0.9.4}/docs/img/settings.png +0 -0
  37. {intervalssync-0.9.3 → intervalssync-0.9.4}/docs/img/sync.png +0 -0
  38. {intervalssync-0.9.3 → intervalssync-0.9.4}/main.py +0 -0
  39. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/__init__.py +0 -0
  40. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/_dropbox_app_key.py +0 -0
  41. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/__init__.py +0 -0
  42. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/api.py +0 -0
  43. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/ddp.py +0 -0
  44. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/exceptions.py +0 -0
  45. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/cli/__init__.py +0 -0
  46. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/cli/env.py +0 -0
  47. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/dropbox_client.py +0 -0
  48. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/__init__.py +0 -0
  49. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/app.py +0 -0
  50. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/profile_sync_ui.py +0 -0
  51. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/secrets.py +0 -0
  52. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/settings_view.py +0 -0
  53. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/support_gamification.py +0 -0
  54. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/system.py +0 -0
  55. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/theme.py +0 -0
  56. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/update_check.py +0 -0
  57. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/__init__.py +0 -0
  58. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/interval_info.py +0 -0
  59. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/profile_sync.py +0 -0
  60. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/region.py +0 -0
  61. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/workout.py +0 -0
  62. {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/zone_map.py +0 -0
  63. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/Foundation_2.fit +0 -0
  64. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/Foundation_2.json +0 -0
  65. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/app.fit +0 -0
  66. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/app.json +0 -0
  67. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.fit +0 -0
  68. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.json +0 -0
  69. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_bryton_fit_encode.py +0 -0
  70. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_bryton_fit_native.py +0 -0
  71. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_dropbox_client.py +0 -0
  72. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_gui_app.py +0 -0
  73. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_profile_sync.py +0 -0
  74. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_secrets.py +0 -0
  75. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_support_gamification.py +0 -0
  76. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_update_check.py +0 -0
  77. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_workout.py +0 -0
  78. {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_zone_map.py +0 -0
  79. {intervalssync-0.9.3 → intervalssync-0.9.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: intervalssync
3
- Version: 0.9.3
3
+ Version: 0.9.4
4
4
  Summary: Sync cycling activities from iGPSPORT or Bryton Active to intervals.icu
5
5
  Project-URL: Homepage, https://github.com/jorge-huxley/intervalssync
6
6
  Project-URL: Repository, https://github.com/jorge-huxley/intervalssync
@@ -2,7 +2,7 @@
2
2
  name = "intervalssync"
3
3
  # Placeholder. The real version comes from the git tag, injected by CI at build
4
4
  # time (see .github/workflows/release.yml). Local/dev builds report 0.0.0+dev.
5
- version = "0.9.3"
5
+ version = "0.9.4"
6
6
  description = "Sync cycling activities from iGPSPORT or Bryton Active to intervals.icu"
7
7
  readme = "README.md"
8
8
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.9.4"
@@ -205,10 +205,11 @@ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
205
205
  if not config.intervals_api_key:
206
206
  raise BrytonSyncError("intervals.icu API key is required for upload.")
207
207
 
208
- activity_id = upload_fit_file(
208
+ upload_result = upload_fit_file(
209
209
  fit_path, act.title, ext, config.intervals_api_key
210
210
  )
211
- if activity_id:
211
+ if upload_result:
212
+ activity_id = upload_result.activity_id
212
213
  report(f"✓ Uploaded {act.activity_id}: {act.title}")
213
214
  result.uploaded += 1
214
215
  if config.activity_type:
@@ -361,3 +361,26 @@ def icu_workout_doc_to_bryton_fit(
361
361
  encoder.on_mesg(Profile["mesg_num"]["WORKOUT_STEP"], step)
362
362
 
363
363
  return bytes(encoder.close())
364
+
365
+
366
+ def encoded_step_count(
367
+ workout_doc: dict[str, Any],
368
+ *,
369
+ max_hr: float | None = None,
370
+ ) -> int:
371
+ """Number of steps the encoder will actually write for this workout.
372
+
373
+ Repeats expand into their children plus a repeat marker, so this differs
374
+ from ``len(workout_doc["steps"])``. Bryton's ``info.interval`` array is
375
+ per encoded step, so it has to be built from this, not the raw list.
376
+ """
377
+ raw_steps = workout_doc.get("steps")
378
+ if not isinstance(raw_steps, list) or not raw_steps:
379
+ return 0
380
+ steps = _flatten_steps(
381
+ raw_steps,
382
+ ftp=_num(workout_doc.get("ftp")),
383
+ lthr=_num(workout_doc.get("lthr")),
384
+ max_hr=max_hr if max_hr is not None else _num(workout_doc.get("max_hr")),
385
+ )
386
+ return len(steps or [])
@@ -7,17 +7,23 @@ intervals.icu ``workout_doc`` to Bryton-native FIT and POST multipart to
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import json
10
11
  import re
11
12
  from dataclasses import dataclass, field
12
13
  from datetime import date, datetime, timedelta
13
14
  from typing import Any, Callable
15
+ from uuid import uuid4
14
16
 
15
17
  import requests
16
18
 
17
19
  from .. import intervals_icu
18
20
  from .ddp import WEB_HOST, BrytonSession, call_method, login
19
21
  from .exceptions import BrytonSyncError
20
- from .fit_encode import bryton_hr_uses_mhr, icu_workout_doc_to_bryton_fit
22
+ from .fit_encode import (
23
+ bryton_hr_uses_mhr,
24
+ encoded_step_count,
25
+ icu_workout_doc_to_bryton_fit,
26
+ )
21
27
 
22
28
  _CYCLING_TYPES = frozenset(
23
29
  {
@@ -186,10 +192,46 @@ def _sanitize_workout_filename(name: str, *, event_id: int) -> str:
186
192
 
187
193
 
188
194
  def _auto_workout_filename() -> str:
195
+ # Names are a fallback deduplication key, so time alone is insufficient
196
+ # when multiple uploads (including separate processes) share a timestamp.
189
197
  now = datetime.now()
190
198
  return (
191
199
  f"bsWO{now.month:02d}{now.day:02d}_"
192
- f"{now.hour:02d}{now.minute:02d}{now.second:02d}"
200
+ f"{now.hour:02d}{now.minute:02d}{now.second:02d}_{uuid4().hex}"
201
+ )
202
+
203
+
204
+ def build_workout_info(
205
+ title: str,
206
+ plan_date: str,
207
+ workout_doc: dict[str, Any] | None = None,
208
+ *,
209
+ description: str = "",
210
+ max_hr: float | None = None,
211
+ ) -> str:
212
+ """Build the ``info`` blob Bryton stores alongside a workout file.
213
+
214
+ A workout carries its schedule here, not in the FIT, and there is no
215
+ separate plan object in the API -- ``plan`` is the whole mechanism. An
216
+ upload without this blob is inert: visible and editable in the app, but
217
+ absent from its Training calendar and impossible to send to the head unit.
218
+ TrainingPeaks-sourced workouts arrive with ``plan`` populated, which is why
219
+ they work and plain uploads do not.
220
+ """
221
+ # One entry per ENCODED step: repeats expand in the FIT, so the raw list
222
+ # would be short and the app's step count would disagree with the file.
223
+ n_steps = encoded_step_count(workout_doc, max_hr=max_hr) if workout_doc else 0
224
+ interval = [{"target_type": ""} for _ in range(n_steps)]
225
+ return json.dumps(
226
+ {
227
+ "name": title,
228
+ "description": description,
229
+ "plan": [plan_date] if plan_date else [],
230
+ "provider": "bryton",
231
+ "interval": interval,
232
+ "create_time": int(datetime.now().timestamp() * 1000),
233
+ "ver": 5,
234
+ }
193
235
  )
194
236
 
195
237
 
@@ -198,6 +240,7 @@ def upload_workout_fit(
198
240
  fit_bytes: bytes,
199
241
  name: str,
200
242
  *,
243
+ info: str | None = None,
201
244
  http: requests.Session | None = None,
202
245
  ) -> bool:
203
246
  """Upload a FIT workout to Bryton Active web; return True on success."""
@@ -208,11 +251,14 @@ def upload_workout_fit(
208
251
  "X-User-Id": session.user_id,
209
252
  "X-Auth-Token": session.auth_token,
210
253
  }
254
+ payload = {"name": filename, "provider": "bryton"}
255
+ if info:
256
+ payload["info"] = info
211
257
  client = http or requests.Session()
212
258
  resp = client.post(
213
259
  url,
214
260
  files={"file": (filename, fit_bytes, "application/octet-stream")},
215
- data={"name": filename, "provider": "bryton"},
261
+ data=payload,
216
262
  headers=headers,
217
263
  timeout=120,
218
264
  )
@@ -319,7 +365,17 @@ def upload_workouts(
319
365
  )
320
366
 
321
367
  report(f"Uploading {workout.name}…")
322
- if upload_workout_fit(session, fit_bytes, upload_name, http=http):
368
+ # Direct attribute access, deliberately: a refactor that drops
369
+ # start_date must raise here rather than quietly emit plan:[], which
370
+ # would silently reintroduce the inert-upload bug this commit fixes.
371
+ info = build_workout_info(
372
+ workout.name,
373
+ workout.start_date,
374
+ workout.workout_doc,
375
+ description=workout.description,
376
+ max_hr=encode_max_hr,
377
+ )
378
+ if upload_workout_fit(session, fit_bytes, upload_name, info=info, http=http):
323
379
  after_ids, _ = _fetch_workout_library(session)
324
380
  new_ids = after_ids - ids_before
325
381
  stored_value = next(iter(new_ids), upload_name)
@@ -31,6 +31,8 @@ class CliConfig:
31
31
  delete_after_upload: bool = True
32
32
  force_resync: bool = False
33
33
  activity_type: str = ""
34
+ # iGPSPORT ride id (decimal string) → intervals.icu activity id.
35
+ uploaded_activities: dict[str, str] = field(default_factory=dict)
34
36
  # Planned workouts: intervals.icu event id → iGPSPORT workoutId.
35
37
  uploaded_workouts: dict[str, int] = field(default_factory=dict)
36
38
  # Planned workouts: intervals.icu event id → Bryton FIT filename stem.
@@ -20,6 +20,7 @@ from ..bryton.exceptions import BrytonSyncError
20
20
  from ..igpsport.core import SyncConfig as IgpSyncConfig
21
21
  from ..igpsport.core import SyncError as IgpSyncError
22
22
  from ..igpsport.core import SyncResult as IgpSyncResult
23
+ from ..igpsport.core import apply_uploaded_activity_map
23
24
  from ..igpsport.core import sync as igpsport_sync
24
25
  from ..igpsport.profile_sync import (
25
26
  ProfileSyncConfig,
@@ -68,6 +69,7 @@ def _build_igpsport_sync_config(
68
69
  delete_after_upload if delete_after_upload is not None else config.delete_after_upload
69
70
  ),
70
71
  force_resync=force_resync if force_resync is not None else config.force_resync,
72
+ uploaded_activities=dict(config.uploaded_activities),
71
73
  activity_type=activity_type if activity_type is not None else config.activity_type,
72
74
  list_activities=True,
73
75
  get_download_url=True,
@@ -110,9 +112,11 @@ def _igpsport_result_payload(result: IgpSyncResult, *, ok: bool, error: str | No
110
112
  "source": "igpsport",
111
113
  "listed": result.listed,
112
114
  "uploaded": result.uploaded,
115
+ "linked": result.linked,
113
116
  "skipped": result.skipped,
114
117
  "failed": result.failed,
115
118
  "downloaded": result.downloaded,
119
+ "activity_map": result.activity_map,
116
120
  "activities": [
117
121
  {
118
122
  "ride_id": act.ride_id,
@@ -157,6 +161,7 @@ def _emit_json(payload: dict) -> None:
157
161
  def _emit_igpsport_text_summary(result: IgpSyncResult) -> None:
158
162
  print(
159
163
  f"Done — uploaded {result.uploaded}, "
164
+ f"linked {result.linked}, "
160
165
  f"downloaded {result.downloaded}, "
161
166
  f"skipped {result.skipped}, "
162
167
  f"failed {result.failed}."
@@ -332,8 +337,14 @@ def cmd_sync(args: argparse.Namespace) -> int:
332
337
  download_dir=args.download_dir,
333
338
  delete_after_upload=delete_after_upload,
334
339
  )
340
+ def persist_activity_map(partial_result: IgpSyncResult) -> None:
341
+ apply_uploaded_activity_map(config.uploaded_activities, partial_result)
342
+ cli_config_module.save(config)
343
+
335
344
  try:
336
- result = igpsport_sync(sync_config, progress=progress)
345
+ result = igpsport_sync(
346
+ sync_config, progress=progress, on_activity_map=persist_activity_map
347
+ )
337
348
  except IgpSyncError as exc:
338
349
  if use_json:
339
350
  _emit_json(_igpsport_result_payload(IgpSyncResult(), ok=False, error=str(exc)))
@@ -347,6 +358,10 @@ def cmd_sync(args: argparse.Namespace) -> int:
347
358
  print(f"✗ Unexpected error: {exc}", file=sys.stderr)
348
359
  return EXIT_SYNC_ERROR
349
360
 
361
+ if result.activity_map or result.pruned_keys:
362
+ apply_uploaded_activity_map(config.uploaded_activities, result)
363
+ cli_config_module.save(config)
364
+
350
365
  ok = result.failed == 0
351
366
  if use_json:
352
367
  _emit_json(_igpsport_result_payload(result, ok=ok))
@@ -51,6 +51,8 @@ class AppConfig:
51
51
  upload_dropbox: bool = False
52
52
  dropbox_folder: str = "/intervalssync-fit"
53
53
  dropbox_date_filenames: bool = True
54
+ # iGPSPORT ride id (decimal string) → intervals.icu activity id.
55
+ uploaded_activities: dict[str, str] = field(default_factory=dict)
54
56
  # Planned workouts: intervals.icu event id → iGPSPORT workoutId.
55
57
  uploaded_workouts: dict[str, int] = field(default_factory=dict)
56
58
  # Planned workouts: intervals.icu event id → Bryton file id or filename stem.
@@ -15,7 +15,13 @@ from ..bryton.workout import (
15
15
  apply_uploaded_bryton_workout_map,
16
16
  upload_workouts as bryton_upload_workouts,
17
17
  )
18
- from ..igpsport.core import SyncConfig as IgpSyncConfig, SyncError, sync as igpsport_sync
18
+ from ..igpsport.core import (
19
+ SyncConfig as IgpSyncConfig,
20
+ SyncError,
21
+ SyncResult as IgpSyncResult,
22
+ apply_uploaded_activity_map,
23
+ sync as igpsport_sync,
24
+ )
19
25
  from ..dropbox_client import get_dropbox_app_key
20
26
  from ..igpsport.workout import WorkoutUploadConfig, apply_uploaded_workout_map, upload_workouts
21
27
  from . import support_gamification
@@ -187,6 +193,7 @@ def build_sync_view(
187
193
  dropbox_app_key: str | None,
188
194
  ) -> None:
189
195
  sync_config = IgpSyncConfig(
196
+ uploaded_activities=dict(config.uploaded_activities),
190
197
  igp_user=config.igp_user,
191
198
  igp_password=igp_password,
192
199
  igp_region=config.igp_region,
@@ -207,12 +214,22 @@ def build_sync_view(
207
214
  dropbox_date_filenames=config.dropbox_date_filenames,
208
215
  )
209
216
 
217
+ def persist_activity_map(partial_result: IgpSyncResult) -> None:
218
+ apply_uploaded_activity_map(config.uploaded_activities, partial_result)
219
+ config_module.save(config)
220
+
210
221
  uploaded_count = 0
211
222
  try:
212
- result = igpsport_sync(sync_config, progress=append_log)
223
+ result = igpsport_sync(
224
+ sync_config, progress=append_log, on_activity_map=persist_activity_map
225
+ )
226
+ if result.activity_map or result.pruned_keys:
227
+ apply_uploaded_activity_map(config.uploaded_activities, result)
228
+ config_module.save(config)
213
229
  uploaded_count = result.uploaded
214
230
  append_log(
215
231
  f"\nDone — intervals uploaded {result.uploaded}, "
232
+ f"linked {result.linked}, "
216
233
  f"Dropbox uploaded {result.uploaded_dropbox}, "
217
234
  f"downloaded {result.downloaded}, "
218
235
  f"skipped {result.skipped}, Dropbox skipped {result.skipped_dropbox}, "
@@ -28,7 +28,10 @@ from ..dropbox_client import (
28
28
  upload_to_dropbox,
29
29
  )
30
30
  from ..intervals_icu import (
31
- fetch_uploaded_external_ids as icu_fetch_uploaded_external_ids,
31
+ ActivityIdentities,
32
+ ActivityUploadResult,
33
+ activity_exists as icu_activity_exists,
34
+ fetch_activity_identities as icu_fetch_activity_identities,
32
35
  set_activity_type,
33
36
  upload_fit_file,
34
37
  )
@@ -118,12 +121,15 @@ class SyncResult:
118
121
  listed: int = 0
119
122
  downloaded: int = 0
120
123
  uploaded: int = 0
124
+ linked: int = 0
121
125
  uploaded_dropbox: int = 0
122
126
  skipped: int = 0
123
127
  skipped_dropbox: int = 0
124
128
  failed: int = 0
125
129
  failed_dropbox: int = 0
126
130
  activities: list[Activity] = field(default_factory=list)
131
+ activity_map: dict[str, str] = field(default_factory=dict)
132
+ pruned_keys: list[str] = field(default_factory=list)
127
133
 
128
134
 
129
135
  # A progress callback receives short, human-readable status events. It is
@@ -300,18 +306,23 @@ def download_fit(fit_url: str, dest_path: Path) -> Path:
300
306
 
301
307
  def upload_to_intervals(
302
308
  fit_path: Path, title: str, ride_id: int, api_key: str
303
- ) -> str | None:
304
- """Upload a .fit file to intervals.icu; return the new activity id or None."""
309
+ ) -> ActivityUploadResult | None:
310
+ """Upload a .fit file to intervals.icu and return its identity result."""
305
311
  return upload_fit_file(
306
312
  fit_path, title, external_id_for(ride_id), api_key
307
313
  )
308
314
 
309
315
 
310
- def fetch_uploaded_external_ids(
316
+ def fetch_activity_identities(
311
317
  api_key: str, oldest: date, newest: date
312
- ) -> set[str]:
313
- """Return external_ids for rides already on intervals.icu in a date range."""
314
- return icu_fetch_uploaded_external_ids(api_key, oldest, newest)
318
+ ) -> ActivityIdentities:
319
+ """Return activity IDs and external-ID links in an Intervals date range."""
320
+ return icu_fetch_activity_identities(api_key, oldest, newest)
321
+
322
+
323
+ def activity_exists(api_key: str, activity_id: str) -> bool:
324
+ """Return whether a mapped Intervals activity still exists."""
325
+ return icu_activity_exists(api_key, activity_id)
315
326
 
316
327
 
317
328
  def _activity_date_range(activities: list[Activity]) -> tuple[date, date]:
@@ -346,6 +357,8 @@ class SyncConfig:
346
357
  # When False (default), skip activities already uploaded to intervals.icu.
347
358
  # When True, re-download and re-upload them regardless.
348
359
  force_resync: bool = False
360
+ # iGPSPORT ride id (decimal string) → intervals.icu activity id.
361
+ uploaded_activities: dict[str, str] = field(default_factory=dict)
349
362
  # Sport to set on uploaded activities (intervals.icu doesn't accept it on
350
363
  # upload). Empty string = leave the uploaded sport untouched.
351
364
  activity_type: str = ACTIVITY_TYPE_KEEP
@@ -358,8 +371,27 @@ class SyncConfig:
358
371
  dropbox_date_filenames: bool = True
359
372
 
360
373
 
361
- def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
362
- """Run the configured steps end-to-end, reporting progress via the callback."""
374
+ def apply_uploaded_activity_map(
375
+ uploaded_activities: dict[str, str],
376
+ result: SyncResult,
377
+ ) -> None:
378
+ """Merge activity identities into config and remove verified deletions."""
379
+ for key in result.pruned_keys:
380
+ uploaded_activities.pop(key, None)
381
+ uploaded_activities.update(result.activity_map)
382
+
383
+
384
+ def sync(
385
+ config: SyncConfig,
386
+ progress: Progress | None = None,
387
+ *,
388
+ on_activity_map: Callable[[SyncResult], None] | None = None,
389
+ ) -> SyncResult:
390
+ """Sync rides, checkpointing verified identities before subsequent I/O.
391
+
392
+ Front-ends can persist the cumulative identity map with on_activity_map.
393
+ Persistence errors propagate so we stop before doing more remote work.
394
+ """
363
395
  report = progress or _noop
364
396
  result = SyncResult()
365
397
 
@@ -387,21 +419,50 @@ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
387
419
  if not needs_url:
388
420
  return result
389
421
 
390
- # Figure out which activities are already on intervals.icu so we can skip
391
- # re-downloading them. Only relevant when uploading and not forcing a resync.
392
- already_uploaded: set[str] = set()
422
+ # Resolve every Intervals identity before processing any ride. This keeps a
423
+ # later verification failure from occurring after earlier FIT downloads.
424
+ intervals_skip: set[str] = set()
393
425
  if config.upload_intervals and not config.force_resync and config.intervals_api_key:
394
426
  oldest, newest = _activity_date_range(activities)
395
427
  try:
396
- already_uploaded = fetch_uploaded_external_ids(
428
+ identities = fetch_activity_identities(
397
429
  config.intervals_api_key, oldest, newest
398
430
  )
399
- report(
400
- f"{len(already_uploaded)} activities already on intervals.icu "
401
- f"in {oldest.isoformat()}…{newest.isoformat()}."
402
- )
403
- except requests.RequestException as exc:
404
- report(f"⚠ Could not check intervals.icu (will process all): {exc}")
431
+ except (requests.RequestException, ValueError) as exc:
432
+ raise SyncError(f"Could not check intervals.icu activities: {exc}") from exc
433
+
434
+ report(
435
+ f"{len(identities.activity_ids)} activities already on intervals.icu "
436
+ f"in {oldest.isoformat()}…{newest.isoformat()}."
437
+ )
438
+ for act in activities:
439
+ ride_key = str(act.ride_id)
440
+ external_id = external_id_for(act.ride_id)
441
+ discovered_id = identities.external_ids.get(external_id)
442
+ if discovered_id is not None:
443
+ intervals_skip.add(ride_key)
444
+ result.activity_map[ride_key] = discovered_id
445
+ continue
446
+
447
+ mapped_id = config.uploaded_activities.get(ride_key)
448
+ if mapped_id is None:
449
+ continue
450
+ if mapped_id in identities.activity_ids:
451
+ intervals_skip.add(ride_key)
452
+ continue
453
+ try:
454
+ mapped_exists = activity_exists(config.intervals_api_key, mapped_id)
455
+ except requests.RequestException as exc:
456
+ raise SyncError(
457
+ f"Could not verify intervals.icu activity {mapped_id}: {exc}"
458
+ ) from exc
459
+ if mapped_exists:
460
+ intervals_skip.add(ride_key)
461
+ else:
462
+ result.pruned_keys.append(ride_key)
463
+
464
+ if on_activity_map and (result.activity_map or result.pruned_keys):
465
+ on_activity_map(result)
405
466
 
406
467
  # Validate Dropbox prerequisites once, before processing any activity, so a
407
468
  # misconfiguration fails fast instead of part-way through the loop.
@@ -430,13 +491,14 @@ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
430
491
  any_upload_enabled = config.upload_intervals or config.upload_dropbox
431
492
 
432
493
  for act in activities:
494
+ ride_key = str(act.ride_id)
433
495
  ext = external_id_for(act.ride_id)
434
496
  fit_path = download_dir / f"{ext}.fit"
435
497
  dropbox_filename = dropbox_filename_for(act, config.dropbox_date_filenames)
436
498
 
437
499
  # Each target tracks its own "already there" state, so an activity can
438
500
  # be uploaded to one target while being skipped on the other.
439
- intervals_needs_it = config.upload_intervals and ext not in already_uploaded
501
+ intervals_needs_it = config.upload_intervals and ride_key not in intervals_skip
440
502
  dropbox_needs_it = (
441
503
  config.upload_dropbox and dropbox_filename not in dropbox_uploaded_names
442
504
  )
@@ -473,14 +535,23 @@ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
473
535
  if not config.intervals_api_key:
474
536
  raise SyncError("intervals.icu API key is required for upload.")
475
537
 
476
- activity_id = upload_to_intervals(
538
+ upload_result = upload_to_intervals(
477
539
  fit_path, act.title, act.ride_id, config.intervals_api_key
478
540
  )
479
- if activity_id:
480
- report(f"✓ Uploaded {act.ride_id}: {act.title}")
481
- result.uploaded += 1
482
-
483
- if config.activity_type:
541
+ if upload_result:
542
+ activity_id = upload_result.activity_id
543
+ result.activity_map[ride_key] = activity_id
544
+ if upload_result.created:
545
+ report(f"✓ Uploaded {act.ride_id}: {act.title}")
546
+ result.uploaded += 1
547
+ else:
548
+ report(f"↔ Linked {act.ride_id}: {act.title}")
549
+ result.linked += 1
550
+
551
+ if on_activity_map:
552
+ on_activity_map(result)
553
+
554
+ if upload_result.created and config.activity_type:
484
555
  if set_activity_type(
485
556
  activity_id, config.activity_type, config.intervals_api_key
486
557
  ):
@@ -27,6 +27,19 @@ class CalendarWorkout:
27
27
  description: str
28
28
  activity_type: str
29
29
  workout_doc: dict[str, Any]
30
+ start_date: str = ""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ActivityUploadResult:
35
+ activity_id: str
36
+ created: bool
37
+
38
+
39
+ @dataclass
40
+ class ActivityIdentities:
41
+ activity_ids: set[str]
42
+ external_ids: dict[str, str]
30
43
 
31
44
 
32
45
  @dataclass(frozen=True)
@@ -58,10 +71,18 @@ def _num_list(value: Any) -> list[float]:
58
71
  return out
59
72
 
60
73
 
74
+ def _parse_activity_id(value: Any) -> str | None:
75
+ """Return a normalized Intervals identifier or None for unsupported values."""
76
+ if not isinstance(value, str):
77
+ return None
78
+ normalized = value.strip()
79
+ return normalized or None
80
+
81
+
61
82
  def upload_fit_file(
62
83
  fit_path: Path, title: str, external_id: str, api_key: str
63
- ) -> str | None:
64
- """Upload a .fit file; return the new activity id or None on failure."""
84
+ ) -> ActivityUploadResult | None:
85
+ """Upload a .fit file and classify whether Intervals created or linked it."""
65
86
  with fit_path.open("rb") as f:
66
87
  resp = requests.post(
67
88
  INTERVALS_UPLOAD_URL,
@@ -72,11 +93,28 @@ def upload_fit_file(
72
93
  if resp.status_code not in (200, 201):
73
94
  return None
74
95
 
75
- data = resp.json()
96
+ try:
97
+ data = resp.json()
98
+ except ValueError:
99
+ return None
100
+ if not isinstance(data, dict):
101
+ return None
76
102
  activities = data.get("activities") or []
77
- if activities and activities[0].get("id"):
78
- return activities[0]["id"]
79
- return data.get("id")
103
+ activity_id = None
104
+ if (
105
+ isinstance(activities, list)
106
+ and activities
107
+ and isinstance(activities[0], dict)
108
+ ):
109
+ activity_id = _parse_activity_id(activities[0].get("id"))
110
+ if activity_id is None:
111
+ activity_id = _parse_activity_id(data.get("id"))
112
+ if activity_id is None:
113
+ return None
114
+ return ActivityUploadResult(
115
+ activity_id=activity_id,
116
+ created=resp.status_code == 201,
117
+ )
80
118
 
81
119
 
82
120
  def set_activity_type(activity_id: str, activity_type: str, api_key: str) -> bool:
@@ -89,21 +127,61 @@ def set_activity_type(activity_id: str, activity_type: str, api_key: str) -> boo
89
127
  return resp.ok
90
128
 
91
129
 
130
+ def fetch_activity_identities(
131
+ api_key: str, oldest: date, newest: date
132
+ ) -> ActivityIdentities:
133
+ """Return Intervals activity IDs and external-ID links in a date range."""
134
+ resp = requests.get(
135
+ INTERVALS_ACTIVITIES_URL,
136
+ params={"oldest": oldest.isoformat(), "newest": newest.isoformat()},
137
+ auth=("API_KEY", api_key),
138
+ timeout=30,
139
+ )
140
+ resp.raise_for_status()
141
+ data = resp.json()
142
+ if not isinstance(data, list):
143
+ raise ValueError("intervals.icu activities response must be a list")
144
+ activity_ids: set[str] = set()
145
+ external_ids: dict[str, str] = {}
146
+ for activity in data:
147
+ if not isinstance(activity, dict) or "id" not in activity:
148
+ continue
149
+ activity_id = _parse_activity_id(activity["id"])
150
+ if activity_id is None:
151
+ raise ValueError("intervals.icu activity id must be a nonempty string")
152
+ activity_ids.add(activity_id)
153
+ external_id = activity.get("external_id")
154
+ if external_id is not None:
155
+ external_id = _parse_activity_id(external_id)
156
+ if external_id is None:
157
+ raise ValueError("intervals.icu external_id must be a nonempty string")
158
+ external_ids[external_id] = activity_id
159
+ return ActivityIdentities(activity_ids, external_ids)
160
+
161
+
92
162
  def fetch_uploaded_external_ids(
93
163
  api_key: str, oldest: date, newest: date
94
164
  ) -> set[str]:
95
165
  """Return external_ids already on intervals.icu in a date range."""
166
+ return set(fetch_activity_identities(api_key, oldest, newest).external_ids)
167
+
168
+
169
+ def activity_exists(api_key: str, activity_id: str) -> bool:
170
+ """Return whether an activity exists, raising if absence cannot be verified."""
96
171
  resp = requests.get(
97
- INTERVALS_ACTIVITIES_URL,
98
- params={"oldest": oldest.isoformat(), "newest": newest.isoformat()},
172
+ f"{INTERVALS_ACTIVITY_URL}/{activity_id}",
99
173
  auth=("API_KEY", api_key),
174
+ timeout=30,
100
175
  )
176
+ if resp.status_code == 404:
177
+ return False
178
+ if resp.status_code == 200:
179
+ return True
101
180
  resp.raise_for_status()
102
- return {
103
- a["external_id"]
104
- for a in resp.json()
105
- if isinstance(a, dict) and a.get("external_id")
106
- }
181
+ raise requests.HTTPError(
182
+ f"Unexpected HTTP {resp.status_code} verifying activity {activity_id}",
183
+ response=resp,
184
+ )
107
185
 
108
186
 
109
187
  def fetch_calendar_workouts(
@@ -141,6 +219,7 @@ def fetch_calendar_workouts(
141
219
  description=str(event.get("description") or ""),
142
220
  activity_type=str(event.get("type") or "Ride"),
143
221
  workout_doc=workout_doc,
222
+ start_date=str(event.get("start_date_local") or "")[:10],
144
223
  )
145
224
  )
146
225
  return workouts