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.
- {intervalssync-0.9.3 → intervalssync-0.9.4}/PKG-INFO +1 -1
- {intervalssync-0.9.3 → intervalssync-0.9.4}/pyproject.toml +1 -1
- intervalssync-0.9.4/src/intervalssync/_version.py +1 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/core.py +3 -2
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/fit_encode.py +23 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/workout.py +60 -4
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/cli/config.py +2 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/cli/main.py +16 -1
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/config.py +2 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/sync_view.py +19 -2
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/core.py +97 -26
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/intervals_icu.py +92 -13
- intervalssync-0.9.4/tests/test_activity_checkpoints.py +87 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_bryton_core.py +7 -2
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_bryton_workout.py +176 -2
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_cli.py +93 -7
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_config.py +3 -0
- intervalssync-0.9.4/tests/test_gui_sync.py +84 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_igpsport_core.py +551 -18
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_intervals_icu.py +4 -0
- intervalssync-0.9.3/src/intervalssync/_version.py +0 -1
- {intervalssync-0.9.3 → intervalssync-0.9.4}/.env.example +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/.github/FUNDING.yml +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/.github/pull_request_template.md +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/.github/workflows/release.yml +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/.github/workflows/test.yml +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/.gitignore +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/.python-version +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/CLAUDE.md +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/CONTRIBUTING.md +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/LICENSE +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/README.md +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/assets/icon.png +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/docs/AGENT.md +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/docs/img/demo.gif +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/docs/img/settings.png +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/docs/img/sync.png +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/main.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/_dropbox_app_key.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/api.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/ddp.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/bryton/exceptions.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/cli/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/cli/env.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/dropbox_client.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/app.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/profile_sync_ui.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/secrets.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/settings_view.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/support_gamification.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/system.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/theme.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/gui/update_check.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/__init__.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/interval_info.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/profile_sync.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/region.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/workout.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/src/intervalssync/igpsport/zone_map.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/Foundation_2.fit +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/Foundation_2.json +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/app.fit +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/app.json +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.fit +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/fixtures/bryton_workouts/web_bsWO0618_011758.json +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_bryton_fit_encode.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_bryton_fit_native.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_dropbox_client.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_gui_app.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_profile_sync.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_secrets.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_support_gamification.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_update_check.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_workout.py +0 -0
- {intervalssync-0.9.3 → intervalssync-0.9.4}/tests/test_zone_map.py +0 -0
- {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
|
+
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.
|
|
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
|
-
|
|
208
|
+
upload_result = upload_fit_file(
|
|
209
209
|
fit_path, act.title, ext, config.intervals_api_key
|
|
210
210
|
)
|
|
211
|
-
if
|
|
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
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
) ->
|
|
304
|
-
"""Upload a .fit file to intervals.icu
|
|
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
|
|
316
|
+
def fetch_activity_identities(
|
|
311
317
|
api_key: str, oldest: date, newest: date
|
|
312
|
-
) ->
|
|
313
|
-
"""Return
|
|
314
|
-
return
|
|
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
|
|
362
|
-
|
|
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
|
-
#
|
|
391
|
-
#
|
|
392
|
-
|
|
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
|
-
|
|
428
|
+
identities = fetch_activity_identities(
|
|
397
429
|
config.intervals_api_key, oldest, newest
|
|
398
430
|
)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
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
|
-
|
|
538
|
+
upload_result = upload_to_intervals(
|
|
477
539
|
fit_path, act.title, act.ride_id, config.intervals_api_key
|
|
478
540
|
)
|
|
479
|
-
if
|
|
480
|
-
|
|
481
|
-
result.
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
) ->
|
|
64
|
-
"""Upload a .fit file
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|