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,143 @@
1
+ """Map intervals.icu zone definitions onto iGPSPORT fixed zone slots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ POWER_INTERIOR_CAP = 1999
8
+ POWER_LAST_ZONE_END = 2500
9
+ # intervals.icu's open-ended top power zone (e.g. "151%+") has no real upper
10
+ # bound; its API reports this zone's upper bound as this sentinel percentage
11
+ # rather than omitting it.
12
+ OPEN_ZONE_SENTINEL_PCT = 999
13
+
14
+
15
+ def power_upper_bounds_watts(power_zones_pct: list[float], ftp: float) -> list[int]:
16
+ """Convert intervals % FTP upper bounds to absolute watt boundaries."""
17
+ if ftp <= 0 or not power_zones_pct:
18
+ return []
19
+ return [int(round(ftp * pct / 100)) for pct in power_zones_pct]
20
+
21
+
22
+ def hr_upper_bounds_bpm(hr_zones_bpm: list[float], max_hr: float) -> list[int]:
23
+ """Return intervals HR zone upper bounds capped at max_hr."""
24
+ if max_hr <= 0 or not hr_zones_bpm:
25
+ return []
26
+ cap = int(round(max_hr))
27
+ bounds = [int(round(bpm)) for bpm in hr_zones_bpm]
28
+ bounds[-1] = min(bounds[-1], cap)
29
+ return bounds
30
+
31
+
32
+ def _prepare_intervals_ends(intervals_ends: list[int], slot_count: int, cap: int) -> list[int]:
33
+ """Drop open-ended last intervals bound when extra iGPSPORT slots need the cap."""
34
+ ends = list(intervals_ends)
35
+ n = len(ends)
36
+ p = slot_count
37
+ pad_count = p - n
38
+ if pad_count > 0 and n >= 2 and ends[-1] >= cap - pad_count:
39
+ ends = ends[:-1]
40
+ return ends
41
+
42
+
43
+ def _enforce_strictly_increasing(ends: list[int], cap: int) -> list[int]:
44
+ """Ensure zone end values strictly increase and the last is capped."""
45
+ if not ends:
46
+ return ends
47
+ out = [int(value) for value in ends]
48
+ for index in range(1, len(out)):
49
+ if out[index] <= out[index - 1]:
50
+ out[index] = out[index - 1] + 1
51
+ out[-1] = min(out[-1], cap)
52
+ if len(out) > 1 and out[-1] <= out[-2]:
53
+ index = len(out) - 2
54
+ while index > 0 and out[index] >= out[index + 1]:
55
+ out[index] = out[index + 1] - 1
56
+ index -= 1
57
+ if out[0] >= out[1]:
58
+ out[0] = max(0, out[1] - 1)
59
+ return out
60
+
61
+
62
+ def _slot_end_values(intervals_ends: list[int], slot_count: int, cap: int) -> list[int]:
63
+ """Map intervals zone count onto a fixed iGPSPORT slot count."""
64
+ if slot_count <= 0:
65
+ return []
66
+ if not intervals_ends:
67
+ return list(range(cap, cap - slot_count, -1))
68
+
69
+ ends = _prepare_intervals_ends(intervals_ends, slot_count, cap)
70
+ n = len(ends)
71
+ p = slot_count
72
+ pad_count = p - n
73
+
74
+ if n == p:
75
+ result = list(ends)
76
+ elif n < p:
77
+ result = list(ends)
78
+ for index in range(pad_count):
79
+ result.append(cap - pad_count + 1 + index)
80
+ else:
81
+ result = list(ends[: p - 1])
82
+ result.append(ends[-1])
83
+
84
+ return _enforce_strictly_increasing(result, cap)
85
+
86
+
87
+ def _apply_end_values(
88
+ end_values: list[int],
89
+ igpsport_zones: list[dict[str, Any]],
90
+ ) -> list[dict[str, Any]]:
91
+ """Rewrite start/end on iGPSPORT zone dicts, preserving other fields."""
92
+ out: list[dict[str, Any]] = []
93
+ start = 0
94
+ for index, zone in enumerate(igpsport_zones):
95
+ if not isinstance(zone, dict):
96
+ continue
97
+ updated = dict(zone)
98
+ updated["start"] = start
99
+ updated["end"] = end_values[index]
100
+ start = end_values[index]
101
+ out.append(updated)
102
+ return out
103
+
104
+
105
+ def map_power_zones(
106
+ intervals_pct: list[float],
107
+ ftp: float,
108
+ igpsport_zones: list[dict[str, Any]],
109
+ ) -> list[dict[str, Any]]:
110
+ """Map intervals.icu power zones onto iGPSPORT power slots."""
111
+ if not igpsport_zones:
112
+ return []
113
+ intervals_ends = power_upper_bounds_watts(intervals_pct, ftp)
114
+ if (
115
+ intervals_pct
116
+ and intervals_pct[-1] >= OPEN_ZONE_SENTINEL_PCT
117
+ and len(intervals_ends) > 1
118
+ ):
119
+ # Drop the open-ended zone's sentinel bound; POWER_LAST_ZONE_END covers it.
120
+ intervals_ends = intervals_ends[:-1]
121
+ slot_count = len(igpsport_zones)
122
+ if slot_count == 1:
123
+ slot_ends = [POWER_LAST_ZONE_END]
124
+ else:
125
+ interior = _slot_end_values(intervals_ends, slot_count - 1, POWER_INTERIOR_CAP)
126
+ interior[-1] = min(interior[-1], POWER_INTERIOR_CAP)
127
+ interior = _enforce_strictly_increasing(interior, POWER_INTERIOR_CAP)
128
+ slot_ends = interior + [POWER_LAST_ZONE_END]
129
+ return _apply_end_values(slot_ends, igpsport_zones)
130
+
131
+
132
+ def map_hr_zones(
133
+ intervals_bpm: list[float],
134
+ max_hr: float,
135
+ igpsport_zones: list[dict[str, Any]],
136
+ ) -> list[dict[str, Any]]:
137
+ """Map intervals.icu HR zones onto iGPSPORT heart-rate slots."""
138
+ if not igpsport_zones:
139
+ return []
140
+ cap = int(round(max_hr))
141
+ intervals_ends = hr_upper_bounds_bpm(intervals_bpm, max_hr)
142
+ slot_ends = _slot_end_values(intervals_ends, len(igpsport_zones), cap)
143
+ return _apply_end_values(slot_ends, igpsport_zones)
@@ -0,0 +1,201 @@
1
+ """Shared intervals.icu API helpers.
2
+
3
+ Activity upload/dedup, calendar workout fetch, and sport-settings lookup.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import date
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import requests
14
+
15
+ INTERVALS_UPLOAD_URL = "https://intervals.icu/api/v1/athlete/0/activities"
16
+ INTERVALS_ACTIVITIES_URL = "https://intervals.icu/api/v1/athlete/0/activities"
17
+ INTERVALS_ACTIVITY_URL = "https://intervals.icu/api/v1/activity"
18
+ INTERVALS_EVENTS_URL = "https://intervals.icu/api/v1/athlete/0/events"
19
+ INTERVALS_SPORT_SETTINGS_URL = "https://intervals.icu/api/v1/athlete/0/sport-settings"
20
+ INTERVALS_ATHLETE_URL = "https://intervals.icu/api/v1/athlete/0"
21
+
22
+
23
+ @dataclass
24
+ class CalendarWorkout:
25
+ event_id: int
26
+ name: str
27
+ description: str
28
+ activity_type: str
29
+ workout_doc: dict[str, Any]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SportSettings:
34
+ ftp: float | None
35
+ lthr: float | None
36
+ max_hr: float | None
37
+ power_zones: list[float]
38
+ hr_zones: list[float]
39
+
40
+
41
+ def _num(value: Any) -> float | None:
42
+ if value is None:
43
+ return None
44
+ try:
45
+ return float(value)
46
+ except (TypeError, ValueError):
47
+ return None
48
+
49
+
50
+ def _num_list(value: Any) -> list[float]:
51
+ if not isinstance(value, list):
52
+ return []
53
+ out: list[float] = []
54
+ for item in value:
55
+ num = _num(item)
56
+ if num is not None:
57
+ out.append(num)
58
+ return out
59
+
60
+
61
+ def upload_fit_file(
62
+ 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."""
65
+ with fit_path.open("rb") as f:
66
+ resp = requests.post(
67
+ INTERVALS_UPLOAD_URL,
68
+ params={"name": title, "external_id": external_id},
69
+ files={"file": (fit_path.name, f, "application/octet-stream")},
70
+ auth=("API_KEY", api_key),
71
+ )
72
+ if resp.status_code not in (200, 201):
73
+ return None
74
+
75
+ data = resp.json()
76
+ activities = data.get("activities") or []
77
+ if activities and activities[0].get("id"):
78
+ return activities[0]["id"]
79
+ return data.get("id")
80
+
81
+
82
+ def set_activity_type(activity_id: str, activity_type: str, api_key: str) -> bool:
83
+ """Set an activity's sport on intervals.icu."""
84
+ resp = requests.put(
85
+ f"{INTERVALS_ACTIVITY_URL}/{activity_id}",
86
+ json={"type": activity_type},
87
+ auth=("API_KEY", api_key),
88
+ )
89
+ return resp.ok
90
+
91
+
92
+ def fetch_uploaded_external_ids(
93
+ api_key: str, oldest: date, newest: date
94
+ ) -> set[str]:
95
+ """Return external_ids already on intervals.icu in a date range."""
96
+ resp = requests.get(
97
+ INTERVALS_ACTIVITIES_URL,
98
+ params={"oldest": oldest.isoformat(), "newest": newest.isoformat()},
99
+ auth=("API_KEY", api_key),
100
+ )
101
+ 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
+ }
107
+
108
+
109
+ def fetch_calendar_workouts(
110
+ api_key: str,
111
+ oldest: date,
112
+ newest: date,
113
+ *,
114
+ http: requests.Session | None = None,
115
+ ) -> list[CalendarWorkout]:
116
+ """Fetch planned workouts from the intervals.icu calendar."""
117
+ client = http or requests.Session()
118
+ resp = client.get(
119
+ INTERVALS_EVENTS_URL,
120
+ params={
121
+ "category": "WORKOUT",
122
+ "resolve": "true",
123
+ "oldest": oldest.isoformat(),
124
+ "newest": newest.isoformat(),
125
+ },
126
+ auth=("API_KEY", api_key),
127
+ timeout=30,
128
+ )
129
+ resp.raise_for_status()
130
+ workouts: list[CalendarWorkout] = []
131
+ for event in resp.json():
132
+ if event.get("category") != "WORKOUT":
133
+ continue
134
+ workout_doc = event.get("workout_doc")
135
+ if not isinstance(workout_doc, dict):
136
+ continue
137
+ workouts.append(
138
+ CalendarWorkout(
139
+ event_id=int(event["id"]),
140
+ name=str(event.get("name") or "Workout"),
141
+ description=str(event.get("description") or ""),
142
+ activity_type=str(event.get("type") or "Ride"),
143
+ workout_doc=workout_doc,
144
+ )
145
+ )
146
+ return workouts
147
+
148
+
149
+ def fetch_sport_settings(
150
+ api_key: str,
151
+ sport: str = "Ride",
152
+ *,
153
+ http: requests.Session | None = None,
154
+ ) -> SportSettings:
155
+ """Return athlete thresholds and zone definitions from intervals.icu sport settings."""
156
+ client = http or requests.Session()
157
+ resp = client.get(
158
+ f"{INTERVALS_SPORT_SETTINGS_URL}/{sport}",
159
+ auth=("API_KEY", api_key),
160
+ timeout=30,
161
+ )
162
+ resp.raise_for_status()
163
+ data = resp.json()
164
+ if not isinstance(data, dict):
165
+ return SportSettings(None, None, None, [], [])
166
+ return SportSettings(
167
+ ftp=_num(data.get("ftp")),
168
+ lthr=_num(data.get("lthr")),
169
+ max_hr=_num(data.get("max_hr")),
170
+ power_zones=_num_list(data.get("power_zones")),
171
+ hr_zones=_num_list(data.get("hr_zones")),
172
+ )
173
+
174
+
175
+ def fetch_sport_settings_max_hr(
176
+ api_key: str,
177
+ sport: str,
178
+ *,
179
+ http: requests.Session | None = None,
180
+ ) -> float | None:
181
+ """Return athlete max HR (bpm) from intervals.icu sport settings."""
182
+ return fetch_sport_settings(api_key, sport, http=http).max_hr
183
+
184
+
185
+ def fetch_athlete_weight(
186
+ api_key: str,
187
+ *,
188
+ http: requests.Session | None = None,
189
+ ) -> float | None:
190
+ """Return athlete weight in kg from intervals.icu (`icu_weight`, else `weight`)."""
191
+ client = http or requests.Session()
192
+ resp = client.get(
193
+ INTERVALS_ATHLETE_URL,
194
+ auth=("API_KEY", api_key),
195
+ timeout=30,
196
+ )
197
+ resp.raise_for_status()
198
+ data = resp.json()
199
+ if not isinstance(data, dict):
200
+ return None
201
+ return _num(data.get("icu_weight")) or _num(data.get("weight"))
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.5
2
+ Name: intervalssync
3
+ Version: 0.9.3
4
+ Summary: Sync cycling activities from iGPSPORT or Bryton Active to intervals.icu
5
+ Project-URL: Homepage, https://github.com/jorge-huxley/intervalssync
6
+ Project-URL: Repository, https://github.com/jorge-huxley/intervalssync
7
+ Project-URL: Issues, https://github.com/jorge-huxley/intervalssync/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: <3.14,>=3.13
11
+ Requires-Dist: dropbox>=12.0.2
12
+ Requires-Dist: flet-permission-handler==0.86.1
13
+ Requires-Dist: flet-secure-storage==0.86.1
14
+ Requires-Dist: flet==0.86.1
15
+ Requires-Dist: garmin-fit-sdk>=21.208.0
16
+ Requires-Dist: packaging>=26.2
17
+ Requires-Dist: platformdirs>=4.0
18
+ Requires-Dist: requests>=2.33.1
19
+ Requires-Dist: websocket-client>=1.8.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ <p align="center">
23
+ <img src="assets/icon.png" width="120" alt="Intervals Sync logo">
24
+ </p>
25
+
26
+ <h1 align="center">Intervals Sync</h1>
27
+
28
+ <p align="center">
29
+ <strong>The missing intervals.icu sync tool for iGPSPORT and Bryton users.</strong>
30
+ </p>
31
+
32
+ <p align="center">
33
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/jorge-huxley/intervalssync?style=flat" alt="License: MIT" /></a>
34
+ <a href="https://github.com/jorge-huxley/intervalssync/releases"><img src="https://img.shields.io/github/v/release/jorge-huxley/intervalssync?style=flat&sort=semver" alt="GitHub release" /></a>
35
+ <a href="https://github.com/jorge-huxley/intervalssync/releases"><img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Android-lightgrey?style=flat" alt="Platforms" /></a>
36
+ <a href="https://github.com/jorge-huxley/intervalssync/stargazers"><img src="https://img.shields.io/github/stars/jorge-huxley/intervalssync?style=flat&label=stars" alt="GitHub stars" /></a>
37
+
38
+ </p>
39
+
40
+ <p align="center">
41
+ <a href="https://ko-fi.com/jorge_huxley"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi" /></a>
42
+ </p>
43
+
44
+ <p align="center">
45
+ Enter your credentials once, press <strong>Sync</strong>, and your latest rides from
46
+ <strong>iGPSPORT</strong> or <strong>Bryton Active</strong> land on <strong>intervals.icu</strong>. You can also push
47
+ planned workouts from intervals.icu back to iGPSPORT or Bryton Active.
48
+ Free and open source, for <strong>Windows</strong>, <strong>macOS</strong>, and <strong>Android</strong>.
49
+ </p>
50
+
51
+ <p align="center">
52
+ <a href="https://github.com/jorge-huxley/intervalssync/releases/latest">Download</a> &middot;
53
+ <a href="docs/AGENT.md">CLI &amp; agents</a> &middot;
54
+ <a href="CONTRIBUTING.md">Contributing</a>
55
+ </p>
56
+
57
+ ---
58
+
59
+ <br>
60
+
61
+ <p align="center">
62
+ <img src="docs/img/demo.gif" alt="Intervals Sync syncing rides to intervals.icu, skipping ones already uploaded" width="300">
63
+ </p>
64
+
65
+ <details>
66
+ <summary><b>More screenshots</b></summary>
67
+ <br>
68
+ <p align="center">
69
+ <img src="docs/img/sync.png" alt="Sync screen" width="260">
70
+ &nbsp;&nbsp;
71
+ <img src="docs/img/settings.png" alt="Settings screen with secure credential fields" width="260">
72
+ </p>
73
+ </details>
74
+
75
+ ## Features
76
+
77
+ - Syncs recent rides from **iGPSPORT** or **Bryton Active** to **intervals.icu** (original `.fit` files)
78
+ - **Optional Dropbox upload** — mirror activities to a Dropbox folder from the GUI (iGPSPORT or Bryton sync)
79
+ - **Upload workouts** — push planned cycling workouts from your intervals.icu calendar to iGPSPORT custom workouts or Bryton Active (sync to your head unit from the vendor app)
80
+ - **Skips activities already uploaded** so re-running is safe — with an optional *force re-sync*
81
+ - Lets you choose how many recent activities to process
82
+ - **Workout upload window** in Settings — how many calendar days to upload (default: today only)
83
+ - **Sets the intervals.icu sport type** after upload (e.g. Mountain Bike Ride / Gravel Ride) — iGPSPORT exports everything as a generic "Ride"
84
+ - Optionally deletes the local `.fit` files after a successful upload
85
+ - Stores your credentials in the **OS secure vault** (Windows Credential Manager / macOS Keychain / Android Keystore), never in a file
86
+ - Lets you know when a newer version is available
87
+ - **Sync zones to iGPSPORT** — push FTP, LTHR, max HR, weight, and power/HR zones from intervals.icu into your iGPSPORT profile (Settings → iGPSPORT profile, or `intervalssync sync-zones`; the app prompts on launch when thresholds differ)
88
+ - **Headless CLI** — activity sync, workout upload, and iGPSPORT zone/threshold sync from the terminal, with JSON output and exit codes for automation and AI agents (see [CLI & automation](#cli--automation-ai-agents))
89
+ - **China iGPSPORT region** — accounts on [app.igpsport.cn](https://app.igpsport.cn/login) use a separate API; choose **China** in Settings (or `INTERVALSSYNC_IGPSPORT_REGION=china` in the CLI)
90
+
91
+ > Prefer the terminal, or want to help out? It's open source (Python + [Flet](https://flet.dev), MIT) — see [CONTRIBUTING.md](CONTRIBUTING.md).
92
+
93
+ ## Download & run (Windows)
94
+
95
+ 1. Go to the [Releases](../../releases) page and download the latest `.zip`.
96
+ 2. Unzip it anywhere and double-click the app (e.g. `intervalssync.exe`).
97
+ 3. On first launch, open **Settings** and enter:
98
+ - your iGPSPORT **email** and **password** (or **phone number** for China — set region to China if you use [app.igpsport.cn](https://app.igpsport.cn/login))
99
+ - your intervals.icu **API key** (intervals.icu → Settings → Developer)
100
+ 4. Click **Save**, then go back and press **Sync** (or **Upload to iGPSPORT** / **Upload to Bryton** for planned sessions on your intervals.icu calendar).
101
+
102
+ Your password and API key are stored in your operating system's **secure
103
+ credential store** — Windows Credential Manager on Windows (the same vault
104
+ Windows uses for its own logins) — never in a plain text file.
105
+
106
+ ## Download & run (macOS)
107
+
108
+ 1. Go to the [Releases](../../releases) page and download the latest `macos.zip`.
109
+ 2. Unzip it and move the app to **Applications** if you want.
110
+ 3. Open the app, fill in **Settings** (same fields as above), then **Sync** or **Upload to iGPSPORT** / **Upload to Bryton**.
111
+
112
+ On macOS your credentials are stored in Keychain. Release builds are not
113
+ notarized yet, so macOS may require right-clicking the app and choosing
114
+ **Open** the first time.
115
+
116
+ ## Download & run (Android)
117
+
118
+ 1. On the [Releases](../../releases) page, download the latest `.apk`.
119
+ 2. Open it on your phone. Android will ask you to allow installing from this
120
+ source — accept (Settings → "Install unknown apps" for your browser/files app).
121
+ 3. Open the app, fill in **Settings** (same fields as above), then **Sync** or **Upload to iGPSPORT** / **Upload to Bryton**.
122
+
123
+ On Android your credentials are stored in the **Android Keystore**. The app
124
+ isn't on the Play Store, so the "unknown source" prompt is expected.
125
+
126
+ ## CLI & automation (AI agents)
127
+
128
+ Headless `intervalssync` CLI — sync from iGPSPORT or Bryton, upload workouts to iGPSPORT or Bryton, sync thresholds and zones to iGPSPORT, JSON on stdout. See [Agent / headless sync](docs/AGENT.md).
129
+
130
+ With [uv](https://docs.astral.sh/uv/) installed, run the latest stable release
131
+ directly from PyPI—no clone or manual upgrade required:
132
+
133
+ ```bash
134
+ uvx --python 3.13 intervalssync@latest --version
135
+ uvx --python 3.13 intervalssync@latest sync --json
136
+ ```
137
+
138
+ ## Run from source
139
+
140
+ Requires [uv](https://docs.astral.sh/uv/).
141
+
142
+ ```bash
143
+ uv sync
144
+ cp .env.example .env # optional: credentials for CLI; IGPSYNC_DROPBOX_APP_KEY for Dropbox
145
+ uv run --env-file .env intervalssync-gui
146
+ ```
147
+
148
+ ## Build the desktop apps
149
+
150
+ ```bash
151
+ uv run flet build windows
152
+ uv run flet build macos
153
+ ```
154
+
155
+ The distributables land in `build/windows/` and `build/macos/`. (Flet downloads
156
+ the Flutter toolchain on the first build; macOS builds require a macOS host.)
157
+
158
+ ## Roadmap
159
+
160
+ The app is built with [Flet](https://flet.dev), so the same Python code targets
161
+ desktop and Android today, with **iOS** (`flet build ipa`) possible from the same
162
+ code.
163
+
164
+ ## Support
165
+
166
+ Tips on [Ko-fi](https://ko-fi.com/jorge_huxley) help fund bug fixes, releases, and new features. Thank you!
167
+
168
+ ## Acknowledgements
169
+
170
+ This project builds on community work around iGPSPORT activity access and
171
+ syncing:
172
+
173
+ - [kamikadzem22/igpsport-unoffical-api](https://github.com/kamikadzem22/igpsport-unoffical-api)
174
+ for documenting and exploring the unofficial iGPSPORT API.
175
+ - [simple4wan/ride-sync](https://github.com/simple4wan/ride-sync) for prior
176
+ work on syncing ride activities between services (including China-region iGPSPORT).
177
+
178
+ This app is an independent project and is not affiliated with iGPSPORT,
179
+ intervals.icu, or the projects listed above.
@@ -0,0 +1,39 @@
1
+ intervalssync/__init__.py,sha256=tfaMpqkN3HrMSDRvfJTb70ybI4Vv9clRn_61MEkLSL0,101
2
+ intervalssync/_dropbox_app_key.py,sha256=4H1xeJzDWSU2xsPJuyZ40GIHA_-udSdm0fP3AUsXGbI,211
3
+ intervalssync/_version.py,sha256=xKd3pzbczuMsdB08eLAOqZDUd_q1IRxwZ_ccAFL4c4A,22
4
+ intervalssync/dropbox_client.py,sha256=_rkj_wf_cm_XmY-nfPdYav58AWeURXDIG9g26bGOybc,4685
5
+ intervalssync/intervals_icu.py,sha256=O7E5l0EKCAzvXHkttbDjbGKoCjWxiSWux-ES7DmL2Ec,5792
6
+ intervalssync/bryton/__init__.py,sha256=3n9KqkxkOEMxm8NCDAwjFpQbABakoHHZfPO6VIZBoH0,29
7
+ intervalssync/bryton/api.py,sha256=D_pH1hgUqR41xz2Tt603OA-Yh8iDSGv1PhQnNlWFcJ4,1385
8
+ intervalssync/bryton/core.py,sha256=nzucPMb_1Z2inXFTMSK81WZ6PoNzfZ8tYhS865BNrVg,8913
9
+ intervalssync/bryton/ddp.py,sha256=BEWaYdJ1SFB9Oz0MTCgpqx4elYGOVMUioUWigAyRQ_w,7756
10
+ intervalssync/bryton/exceptions.py,sha256=fMp244M9wy10zF_Xo0Ay7RhZ4QJHwKAgl7Qs-9z3ZDg,278
11
+ intervalssync/bryton/fit_encode.py,sha256=i_zn5yM9JdXk4Cp-uDthOWMlb5RcEwm1uDb90r3RwME,10910
12
+ intervalssync/bryton/workout.py,sha256=bxFiKZPT9uMbHGrnfempICLlWCD1D2y5fkUC7aE7Qas,10942
13
+ intervalssync/cli/__init__.py,sha256=W500E5Z06b_ThlFP0aXa_S-4qNpH7qR_U7SnZ93KRxc,104
14
+ intervalssync/cli/config.py,sha256=88dcx8rEOV0lNXEZBJL3cDLqyFXPe6EmgRdZS_lWMzw,1736
15
+ intervalssync/cli/env.py,sha256=TlDHk7orexUgdMOBJDLFG6qHri_5EKC8ZihzzmdXDio,6187
16
+ intervalssync/cli/main.py,sha256=f6hrmTAps4IwwPcGULh1OpLKZRb45INZKz47xEkeBFM,20752
17
+ intervalssync/gui/__init__.py,sha256=dBeFR4kFHTFyRbuTPQgS1rks-d6fzi28C_FhTqr1Ik8,34
18
+ intervalssync/gui/app.py,sha256=dF8AuKGI4FMz7rK5r54aySKPaRAaCjCVK_DB_qg1044,12483
19
+ intervalssync/gui/config.py,sha256=KbxNkw8wm8SeKTglPvuph4RcWAuwm1EsFClp7V1ekbI,4000
20
+ intervalssync/gui/profile_sync_ui.py,sha256=Mrhk6e9I_Me1Vmv3-GEN_CTz92IXHbI23USw863KMA4,6929
21
+ intervalssync/gui/secrets.py,sha256=DehSkxpDovNaWB3cIkCs-jyX8EkwEUuM4zSAG2oh6Og,4114
22
+ intervalssync/gui/settings_view.py,sha256=Yuknxb6xd6K2j0tau7G43VgJjIaUfxJidN0N3kAMbQ8,25858
23
+ intervalssync/gui/support_gamification.py,sha256=tpy0YZdIpLHloina-V9gSZNb6aQ7QdjKkw3bVEFXVyI,19477
24
+ intervalssync/gui/sync_view.py,sha256=AZzaVNCN_9pH5e2yDyhlZ5D-F7GxQFPjlWaVXaMXNss,19191
25
+ intervalssync/gui/system.py,sha256=bEHI5weqM2jhpRtvp89mTPFVLC1YgK2FtdwbJSgvrAk,622
26
+ intervalssync/gui/theme.py,sha256=7Ws6qY8Ym2UvCmlk9sM4y_9063WwsrBUUvprFf_x8M0,5750
27
+ intervalssync/gui/update_check.py,sha256=BmqOEJjBmF9xNixjZ46floF475Z_cqilTl_qQ-jqyOY,1673
28
+ intervalssync/igpsport/__init__.py,sha256=mHBTahHy1YeOIQc2Fl4J74lf2ruQPCBfhOOCW6WJaBE,60
29
+ intervalssync/igpsport/core.py,sha256=m7CB5P88fPBQe0IBmZ4nSlCtDQcnUDxxgdWDTwu_Njk,18257
30
+ intervalssync/igpsport/interval_info.py,sha256=v34SGmcp2fBRdk_e7CGE8xfX1mxcxoM8XyyBlhsXbiM,9558
31
+ intervalssync/igpsport/profile_sync.py,sha256=wJBhYf3urs-4Aj38nE7fBUU3KNCX2e-lnVGghIU7X3E,12716
32
+ intervalssync/igpsport/region.py,sha256=wInjMU2a7h5bV0lgXmP_H5rkytndB0BJJ2l74rFZQTo,2556
33
+ intervalssync/igpsport/workout.py,sha256=zONmbRBW_OoYyBMc1p_p3FBr8fq-YHpMwmvbWEK5myc,15919
34
+ intervalssync/igpsport/zone_map.py,sha256=pHNslrqXsE6mU5C1RtN53Apti_6A6hqV6QA0F5lehV0,4848
35
+ intervalssync-0.9.3.dist-info/METADATA,sha256=HJMG2jZzFsOnAhgDpLyApdf07U5R2fxiebolVOS_fL8,8521
36
+ intervalssync-0.9.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
37
+ intervalssync-0.9.3.dist-info/entry_points.txt,sha256=U4Qpukl4QbpkrmSTDtLx-2ji4NdZ5BW3K7qC-k8ljns,119
38
+ intervalssync-0.9.3.dist-info/licenses/LICENSE,sha256=qiocLTBHBzicxsKt3f85zKhssp0X0NeoBF1dpoYYVPk,1068
39
+ intervalssync-0.9.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ intervalssync = intervalssync.cli:main
3
+
4
+ [gui_scripts]
5
+ intervalssync-gui = intervalssync.gui.app:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jorge Silva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.