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,637 @@
1
+ """Headless CLI for syncing activities to intervals.icu.
2
+
3
+ Supports multiple activity sources (iGPSPORT, Bryton). Credentials are read
4
+ from a .env file; progress goes to stderr; structured results go to stdout with
5
+ --json.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from .. import __version__
16
+ from ..bryton.core import SyncConfig as BrytonSyncConfig
17
+ from ..bryton.core import SyncResult as BrytonSyncResult
18
+ from ..bryton.core import sync as bryton_sync
19
+ from ..bryton.exceptions import BrytonSyncError
20
+ from ..igpsport.core import SyncConfig as IgpSyncConfig
21
+ from ..igpsport.core import SyncError as IgpSyncError
22
+ from ..igpsport.core import SyncResult as IgpSyncResult
23
+ from ..igpsport.core import sync as igpsport_sync
24
+ from ..igpsport.profile_sync import (
25
+ ProfileSyncConfig,
26
+ ProfileSyncResult,
27
+ result_payload as profile_sync_result_payload,
28
+ sync_profile_zones,
29
+ )
30
+ from ..bryton.workout import (
31
+ BrytonWorkoutUploadConfig,
32
+ BrytonWorkoutUploadResult,
33
+ apply_uploaded_bryton_workout_map,
34
+ upload_workouts as bryton_upload_workouts,
35
+ )
36
+ from ..igpsport.workout import (
37
+ WorkoutUploadConfig,
38
+ WorkoutUploadResult,
39
+ apply_uploaded_workout_map,
40
+ upload_workouts,
41
+ )
42
+ from . import config as cli_config_module
43
+ from .env import ActivitySource, CliConfigError, load_credentials, resolve_env_path
44
+
45
+ EXIT_OK = 0
46
+ EXIT_SYNC_ERROR = 1
47
+ EXIT_CONFIG_ERROR = 2
48
+
49
+
50
+ def _build_igpsport_sync_config(
51
+ credentials,
52
+ config: cli_config_module.CliConfig,
53
+ *,
54
+ max_activities: int | None,
55
+ force_resync: bool | None,
56
+ activity_type: str | None,
57
+ download_dir: str | None,
58
+ delete_after_upload: bool | None,
59
+ ) -> IgpSyncConfig:
60
+ return IgpSyncConfig(
61
+ igp_user=credentials.igp_user,
62
+ igp_password=credentials.igp_password,
63
+ intervals_api_key=credentials.intervals_api_key,
64
+ igp_region=credentials.igp_region,
65
+ max_activities=max_activities if max_activities is not None else config.max_activities,
66
+ download_dir=Path(download_dir or config.download_dir),
67
+ delete_after_upload=(
68
+ delete_after_upload if delete_after_upload is not None else config.delete_after_upload
69
+ ),
70
+ force_resync=force_resync if force_resync is not None else config.force_resync,
71
+ activity_type=activity_type if activity_type is not None else config.activity_type,
72
+ list_activities=True,
73
+ get_download_url=True,
74
+ download_fit=True,
75
+ upload_intervals=True,
76
+ upload_dropbox=False,
77
+ )
78
+
79
+
80
+ def _build_bryton_sync_config(
81
+ credentials,
82
+ config: cli_config_module.CliConfig,
83
+ *,
84
+ max_activities: int | None,
85
+ force_resync: bool | None,
86
+ activity_type: str | None,
87
+ download_dir: str | None,
88
+ delete_after_upload: bool | None,
89
+ ) -> BrytonSyncConfig:
90
+ return BrytonSyncConfig(
91
+ bryton_email=credentials.bryton_email,
92
+ bryton_password=credentials.bryton_password,
93
+ intervals_api_key=credentials.intervals_api_key,
94
+ max_activities=max_activities if max_activities is not None else config.max_activities,
95
+ download_dir=Path(download_dir or config.download_dir),
96
+ delete_after_upload=(
97
+ delete_after_upload if delete_after_upload is not None else config.delete_after_upload
98
+ ),
99
+ force_resync=force_resync if force_resync is not None else config.force_resync,
100
+ activity_type=activity_type if activity_type is not None else config.activity_type,
101
+ list_activities=True,
102
+ download_fit=True,
103
+ upload_intervals=True,
104
+ )
105
+
106
+
107
+ def _igpsport_result_payload(result: IgpSyncResult, *, ok: bool, error: str | None = None) -> dict:
108
+ payload = {
109
+ "ok": ok,
110
+ "source": "igpsport",
111
+ "listed": result.listed,
112
+ "uploaded": result.uploaded,
113
+ "skipped": result.skipped,
114
+ "failed": result.failed,
115
+ "downloaded": result.downloaded,
116
+ "activities": [
117
+ {
118
+ "ride_id": act.ride_id,
119
+ "title": act.title,
120
+ "start_time": act.start_time,
121
+ }
122
+ for act in result.activities
123
+ ],
124
+ }
125
+ if error is not None:
126
+ payload["error"] = error
127
+ return payload
128
+
129
+
130
+ def _bryton_result_payload(result: BrytonSyncResult, *, ok: bool, error: str | None = None) -> dict:
131
+ payload = {
132
+ "ok": ok,
133
+ "source": "bryton",
134
+ "listed": result.listed,
135
+ "uploaded": result.uploaded,
136
+ "skipped": result.skipped,
137
+ "failed": result.failed,
138
+ "downloaded": result.downloaded,
139
+ "activities": [
140
+ {
141
+ "activity_id": act.activity_id,
142
+ "title": act.title,
143
+ "start_time": act.start_time,
144
+ }
145
+ for act in result.activities
146
+ ],
147
+ }
148
+ if error is not None:
149
+ payload["error"] = error
150
+ return payload
151
+
152
+
153
+ def _emit_json(payload: dict) -> None:
154
+ print(json.dumps(payload, indent=2))
155
+
156
+
157
+ def _emit_igpsport_text_summary(result: IgpSyncResult) -> None:
158
+ print(
159
+ f"Done — uploaded {result.uploaded}, "
160
+ f"downloaded {result.downloaded}, "
161
+ f"skipped {result.skipped}, "
162
+ f"failed {result.failed}."
163
+ )
164
+
165
+
166
+ def _emit_bryton_text_summary(result: BrytonSyncResult) -> None:
167
+ print(
168
+ f"Done — uploaded {result.uploaded}, "
169
+ f"downloaded {result.downloaded}, "
170
+ f"skipped {result.skipped}, "
171
+ f"failed {result.failed}."
172
+ )
173
+
174
+
175
+ def _build_workout_upload_config(
176
+ credentials,
177
+ config: cli_config_module.CliConfig,
178
+ *,
179
+ workout_days_ahead: int | None,
180
+ force_resync: bool | None,
181
+ ) -> WorkoutUploadConfig:
182
+ return WorkoutUploadConfig(
183
+ igp_user=credentials.igp_user,
184
+ igp_password=credentials.igp_password,
185
+ intervals_api_key=credentials.intervals_api_key,
186
+ igp_region=credentials.igp_region,
187
+ workout_days_ahead=(
188
+ workout_days_ahead if workout_days_ahead is not None else config.workout_days_ahead
189
+ ),
190
+ uploaded_workouts=dict(config.uploaded_workouts),
191
+ force_resync=force_resync if force_resync is not None else config.force_resync,
192
+ )
193
+
194
+
195
+ def _build_bryton_workout_upload_config(
196
+ credentials,
197
+ config: cli_config_module.CliConfig,
198
+ *,
199
+ workout_days_ahead: int | None,
200
+ force_resync: bool | None,
201
+ ) -> BrytonWorkoutUploadConfig:
202
+ return BrytonWorkoutUploadConfig(
203
+ bryton_email=credentials.bryton_email,
204
+ bryton_password=credentials.bryton_password,
205
+ intervals_api_key=credentials.intervals_api_key,
206
+ workout_days_ahead=(
207
+ workout_days_ahead if workout_days_ahead is not None else config.workout_days_ahead
208
+ ),
209
+ uploaded_workouts=dict(config.uploaded_bryton_workouts),
210
+ force_resync=force_resync if force_resync is not None else config.force_resync,
211
+ )
212
+
213
+
214
+ def _workout_result_payload(
215
+ result: WorkoutUploadResult | BrytonWorkoutUploadResult,
216
+ *,
217
+ ok: bool,
218
+ source: str = "igpsport",
219
+ error: str | None = None,
220
+ ) -> dict:
221
+ payload = {
222
+ "ok": ok,
223
+ "source": source,
224
+ "listed": result.listed,
225
+ "uploaded": result.uploaded,
226
+ "skipped": result.skipped,
227
+ "failed": result.failed,
228
+ "no_steps": result.no_steps,
229
+ }
230
+ if error is not None:
231
+ payload["error"] = error
232
+ return payload
233
+
234
+
235
+ def _emit_workout_text_summary(result: WorkoutUploadResult | BrytonWorkoutUploadResult) -> None:
236
+ print(
237
+ f"Done — uploaded {result.uploaded}, "
238
+ f"skipped {result.skipped}, "
239
+ f"no steps {result.no_steps}, "
240
+ f"failed {result.failed}."
241
+ )
242
+
243
+
244
+ def _source_from_args(args: argparse.Namespace) -> ActivitySource:
245
+ return args.source
246
+
247
+
248
+ def cmd_check(args: argparse.Namespace) -> int:
249
+ config = cli_config_module.load()
250
+ source = _source_from_args(args)
251
+ try:
252
+ env_path = resolve_env_path(
253
+ env_file=Path(args.env_file) if args.env_file else None,
254
+ config_env_file=config.env_file or None,
255
+ )
256
+ load_credentials(env_path, source=source)
257
+ except CliConfigError as exc:
258
+ if args.json:
259
+ _emit_json({"ok": False, "source": source, "error": str(exc)})
260
+ else:
261
+ print(exc, file=sys.stderr)
262
+ return EXIT_CONFIG_ERROR
263
+
264
+ if args.json:
265
+ _emit_json({"ok": True, "source": source, "env_file": str(env_path)})
266
+ else:
267
+ print(f"Credentials OK for {source} ({env_path})")
268
+ return EXIT_OK
269
+
270
+
271
+ def cmd_sync(args: argparse.Namespace) -> int:
272
+ config = cli_config_module.load()
273
+ use_json = args.json
274
+ source = _source_from_args(args)
275
+
276
+ try:
277
+ env_path = resolve_env_path(
278
+ env_file=Path(args.env_file) if args.env_file else None,
279
+ config_env_file=config.env_file or None,
280
+ )
281
+ credentials = load_credentials(env_path, source=source)
282
+ except CliConfigError as exc:
283
+ if use_json:
284
+ _emit_json({"ok": False, "source": source, "error": str(exc)})
285
+ else:
286
+ print(exc, file=sys.stderr)
287
+ return EXIT_CONFIG_ERROR
288
+
289
+ delete_after_upload = False if args.keep_files else None
290
+
291
+ def progress(message: str) -> None:
292
+ print(message, file=sys.stderr)
293
+
294
+ if source == "bryton":
295
+ sync_config = _build_bryton_sync_config(
296
+ credentials,
297
+ config,
298
+ max_activities=args.max_activities,
299
+ force_resync=True if args.force_resync else None,
300
+ activity_type=args.activity_type,
301
+ download_dir=args.download_dir,
302
+ delete_after_upload=delete_after_upload,
303
+ )
304
+ try:
305
+ result = bryton_sync(sync_config, progress=progress)
306
+ except BrytonSyncError as exc:
307
+ if use_json:
308
+ _emit_json(_bryton_result_payload(BrytonSyncResult(), ok=False, error=str(exc)))
309
+ else:
310
+ print(f"✗ {exc}", file=sys.stderr)
311
+ return EXIT_SYNC_ERROR
312
+ except Exception as exc: # noqa: BLE001
313
+ if use_json:
314
+ _emit_json(_bryton_result_payload(BrytonSyncResult(), ok=False, error=str(exc)))
315
+ else:
316
+ print(f"✗ Unexpected error: {exc}", file=sys.stderr)
317
+ return EXIT_SYNC_ERROR
318
+
319
+ ok = result.failed == 0
320
+ if use_json:
321
+ _emit_json(_bryton_result_payload(result, ok=ok))
322
+ else:
323
+ _emit_bryton_text_summary(result)
324
+ return EXIT_OK if ok else EXIT_SYNC_ERROR
325
+
326
+ sync_config = _build_igpsport_sync_config(
327
+ credentials,
328
+ config,
329
+ max_activities=args.max_activities,
330
+ force_resync=True if args.force_resync else None,
331
+ activity_type=args.activity_type,
332
+ download_dir=args.download_dir,
333
+ delete_after_upload=delete_after_upload,
334
+ )
335
+ try:
336
+ result = igpsport_sync(sync_config, progress=progress)
337
+ except IgpSyncError as exc:
338
+ if use_json:
339
+ _emit_json(_igpsport_result_payload(IgpSyncResult(), ok=False, error=str(exc)))
340
+ else:
341
+ print(f"✗ {exc}", file=sys.stderr)
342
+ return EXIT_SYNC_ERROR
343
+ except Exception as exc: # noqa: BLE001
344
+ if use_json:
345
+ _emit_json(_igpsport_result_payload(IgpSyncResult(), ok=False, error=str(exc)))
346
+ else:
347
+ print(f"✗ Unexpected error: {exc}", file=sys.stderr)
348
+ return EXIT_SYNC_ERROR
349
+
350
+ ok = result.failed == 0
351
+ if use_json:
352
+ _emit_json(_igpsport_result_payload(result, ok=ok))
353
+ else:
354
+ _emit_igpsport_text_summary(result)
355
+
356
+ return EXIT_OK if ok else EXIT_SYNC_ERROR
357
+
358
+
359
+ def cmd_upload_workouts(args: argparse.Namespace) -> int:
360
+ config = cli_config_module.load()
361
+ use_json = args.json
362
+ source = args.source
363
+
364
+ try:
365
+ env_path = resolve_env_path(
366
+ env_file=Path(args.env_file) if args.env_file else None,
367
+ config_env_file=config.env_file or None,
368
+ )
369
+ credentials = load_credentials(env_path, source=source)
370
+ except CliConfigError as exc:
371
+ if use_json:
372
+ _emit_json({"ok": False, "source": source, "error": str(exc)})
373
+ else:
374
+ print(exc, file=sys.stderr)
375
+ return EXIT_CONFIG_ERROR
376
+
377
+ def progress(message: str) -> None:
378
+ print(message, file=sys.stderr)
379
+
380
+ if source == "bryton":
381
+ upload_config = _build_bryton_workout_upload_config(
382
+ credentials,
383
+ config,
384
+ workout_days_ahead=args.workout_days_ahead,
385
+ force_resync=True if args.force_resync else None,
386
+ )
387
+ try:
388
+ result = bryton_upload_workouts(upload_config, progress=progress)
389
+ except BrytonSyncError as exc:
390
+ if use_json:
391
+ _emit_json(
392
+ _workout_result_payload(
393
+ BrytonWorkoutUploadResult(), ok=False, source=source, error=str(exc)
394
+ )
395
+ )
396
+ else:
397
+ print(f"✗ {exc}", file=sys.stderr)
398
+ return EXIT_SYNC_ERROR
399
+ except Exception as exc: # noqa: BLE001
400
+ if use_json:
401
+ _emit_json(
402
+ _workout_result_payload(
403
+ BrytonWorkoutUploadResult(), ok=False, source=source, error=str(exc)
404
+ )
405
+ )
406
+ else:
407
+ print(f"✗ Unexpected error: {exc}", file=sys.stderr)
408
+ return EXIT_SYNC_ERROR
409
+
410
+ if result.uploaded_map or result.pruned_keys:
411
+ apply_uploaded_bryton_workout_map(config.uploaded_bryton_workouts, result)
412
+ cli_config_module.save(config)
413
+
414
+ ok = result.failed == 0
415
+ if use_json:
416
+ _emit_json(_workout_result_payload(result, ok=ok, source=source))
417
+ else:
418
+ _emit_workout_text_summary(result)
419
+ return EXIT_OK if ok else EXIT_SYNC_ERROR
420
+
421
+ upload_config = _build_workout_upload_config(
422
+ credentials,
423
+ config,
424
+ workout_days_ahead=args.workout_days_ahead,
425
+ force_resync=True if args.force_resync else None,
426
+ )
427
+
428
+ try:
429
+ result = upload_workouts(upload_config, progress=progress)
430
+ except IgpSyncError as exc:
431
+ if use_json:
432
+ _emit_json(
433
+ _workout_result_payload(
434
+ WorkoutUploadResult(), ok=False, source=source, error=str(exc)
435
+ )
436
+ )
437
+ else:
438
+ print(f"✗ {exc}", file=sys.stderr)
439
+ return EXIT_SYNC_ERROR
440
+ except Exception as exc: # noqa: BLE001
441
+ if use_json:
442
+ _emit_json(
443
+ _workout_result_payload(
444
+ WorkoutUploadResult(), ok=False, source=source, error=str(exc)
445
+ )
446
+ )
447
+ else:
448
+ print(f"✗ Unexpected error: {exc}", file=sys.stderr)
449
+ return EXIT_SYNC_ERROR
450
+
451
+ if result.uploaded_map or result.pruned_keys:
452
+ apply_uploaded_workout_map(config.uploaded_workouts, result)
453
+ cli_config_module.save(config)
454
+
455
+ ok = result.failed == 0
456
+ if use_json:
457
+ _emit_json(_workout_result_payload(result, ok=ok, source=source))
458
+ else:
459
+ _emit_workout_text_summary(result)
460
+
461
+ return EXIT_OK if ok else EXIT_SYNC_ERROR
462
+
463
+
464
+ def cmd_sync_zones(args: argparse.Namespace) -> int:
465
+ config = cli_config_module.load()
466
+ use_json = args.json
467
+
468
+ try:
469
+ env_path = resolve_env_path(
470
+ env_file=Path(args.env_file) if args.env_file else None,
471
+ config_env_file=config.env_file or None,
472
+ )
473
+ credentials = load_credentials(env_path, source="igpsport")
474
+ except CliConfigError as exc:
475
+ if use_json:
476
+ _emit_json({"ok": False, "source": "igpsport", "error": str(exc)})
477
+ else:
478
+ print(exc, file=sys.stderr)
479
+ return EXIT_CONFIG_ERROR
480
+
481
+ sync_config = ProfileSyncConfig(
482
+ igp_user=credentials.igp_user,
483
+ igp_password=credentials.igp_password,
484
+ intervals_api_key=credentials.intervals_api_key,
485
+ igp_region=credentials.igp_region,
486
+ sport=args.sport,
487
+ )
488
+
489
+ def progress(message: str) -> None:
490
+ print(message, file=sys.stderr)
491
+
492
+ try:
493
+ result = sync_profile_zones(sync_config, progress=progress)
494
+ except IgpSyncError as exc:
495
+ if use_json:
496
+ _emit_json(profile_sync_result_payload(ProfileSyncResult(None, None), ok=False, error=str(exc)))
497
+ else:
498
+ print(f"✗ {exc}", file=sys.stderr)
499
+ return EXIT_SYNC_ERROR
500
+ except Exception as exc: # noqa: BLE001
501
+ if use_json:
502
+ _emit_json(profile_sync_result_payload(ProfileSyncResult(None, None), ok=False, error=str(exc)))
503
+ else:
504
+ print(f"✗ Unexpected error: {exc}", file=sys.stderr)
505
+ return EXIT_SYNC_ERROR
506
+
507
+ if use_json:
508
+ _emit_json(profile_sync_result_payload(result, ok=True))
509
+ else:
510
+ summary = profile_sync_result_payload(result, ok=True)
511
+ print(
512
+ f"Done — FTP {summary.get('ftp')}, LTHR {summary.get('lthr')}, "
513
+ f"MHR {summary.get('mhr')} synced to iGPSPORT."
514
+ )
515
+ return EXIT_OK
516
+
517
+
518
+ def _add_source_arg(parser: argparse.ArgumentParser) -> None:
519
+ parser.add_argument(
520
+ "--source",
521
+ choices=("igpsport", "bryton"),
522
+ default="igpsport",
523
+ help="Activity source (default: igpsport).",
524
+ )
525
+
526
+
527
+ def _build_parser() -> argparse.ArgumentParser:
528
+ common = argparse.ArgumentParser(add_help=False)
529
+ common.add_argument(
530
+ "--env-file",
531
+ metavar="PATH",
532
+ help="Override path to the secrets .env file.",
533
+ )
534
+ common.add_argument(
535
+ "--json",
536
+ action="store_true",
537
+ help="Emit machine-readable JSON on stdout (progress stays on stderr).",
538
+ )
539
+
540
+ parser = argparse.ArgumentParser(
541
+ prog="intervalssync",
542
+ description="Sync cycling activities to intervals.icu from iGPSPORT or Bryton Active.",
543
+ )
544
+ parser.add_argument(
545
+ "--version",
546
+ action="version",
547
+ version=f"%(prog)s {__version__}",
548
+ )
549
+
550
+ subparsers = parser.add_subparsers(dest="command")
551
+ subparsers.required = True
552
+
553
+ check_parser = subparsers.add_parser(
554
+ "check",
555
+ parents=[common],
556
+ help="Validate credentials in the .env file (no network).",
557
+ )
558
+ _add_source_arg(check_parser)
559
+ check_parser.set_defaults(func=cmd_check)
560
+
561
+ sync_parser = subparsers.add_parser(
562
+ "sync",
563
+ parents=[common],
564
+ help="Download recent rides and upload to intervals.icu.",
565
+ )
566
+ _add_source_arg(sync_parser)
567
+ sync_parser.add_argument(
568
+ "--max-activities",
569
+ type=int,
570
+ metavar="N",
571
+ help="Number of recent activities to process (default: from cli config).",
572
+ )
573
+ sync_parser.add_argument(
574
+ "--force-resync",
575
+ action="store_true",
576
+ help="Re-upload activities even if already on intervals.icu.",
577
+ )
578
+ sync_parser.add_argument(
579
+ "--activity-type",
580
+ metavar="TYPE",
581
+ help='intervals.icu sport to set after upload (e.g. "Mountain Bike Ride").',
582
+ )
583
+ sync_parser.add_argument(
584
+ "--download-dir",
585
+ metavar="PATH",
586
+ help="Directory for temporary .fit files.",
587
+ )
588
+ sync_parser.add_argument(
589
+ "--keep-files",
590
+ action="store_true",
591
+ help="Keep .fit files after upload instead of deleting them.",
592
+ )
593
+ sync_parser.set_defaults(func=cmd_sync)
594
+
595
+ upload_workouts_parser = subparsers.add_parser(
596
+ "upload-workouts",
597
+ parents=[common],
598
+ help="Upload planned workouts from intervals.icu to iGPSPORT or Bryton.",
599
+ )
600
+ _add_source_arg(upload_workouts_parser)
601
+ upload_workouts_parser.add_argument(
602
+ "--workout-days-ahead",
603
+ type=int,
604
+ metavar="N",
605
+ help="Number of calendar days to upload (default: from cli config).",
606
+ )
607
+ upload_workouts_parser.add_argument(
608
+ "--force-resync",
609
+ action="store_true",
610
+ help="Re-upload workouts even if already on the target device platform.",
611
+ )
612
+ upload_workouts_parser.set_defaults(func=cmd_upload_workouts)
613
+
614
+ sync_zones_parser = subparsers.add_parser(
615
+ "sync-zones",
616
+ parents=[common],
617
+ help="Push FTP, LTHR, max HR, weight, and zones from intervals.icu to iGPSPORT.",
618
+ )
619
+ sync_zones_parser.add_argument(
620
+ "--sport",
621
+ default="Ride",
622
+ metavar="TYPE",
623
+ help='intervals.icu sport-settings key (default: "Ride").',
624
+ )
625
+ sync_zones_parser.set_defaults(func=cmd_sync_zones)
626
+
627
+ return parser
628
+
629
+
630
+ def main(argv: list[str] | None = None) -> None:
631
+ parser = _build_parser()
632
+ args = parser.parse_args(argv)
633
+ raise SystemExit(args.func(args))
634
+
635
+
636
+ if __name__ == "__main__":
637
+ main()