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.
- intervalssync/__init__.py +3 -0
- intervalssync/_dropbox_app_key.py +7 -0
- intervalssync/_version.py +1 -0
- intervalssync/bryton/__init__.py +1 -0
- intervalssync/bryton/api.py +49 -0
- intervalssync/bryton/core.py +249 -0
- intervalssync/bryton/ddp.py +231 -0
- intervalssync/bryton/exceptions.py +13 -0
- intervalssync/bryton/fit_encode.py +363 -0
- intervalssync/bryton/workout.py +342 -0
- intervalssync/cli/__init__.py +5 -0
- intervalssync/cli/config.py +53 -0
- intervalssync/cli/env.py +189 -0
- intervalssync/cli/main.py +637 -0
- intervalssync/dropbox_client.py +143 -0
- intervalssync/gui/__init__.py +1 -0
- intervalssync/gui/app.py +352 -0
- intervalssync/gui/config.py +102 -0
- intervalssync/gui/profile_sync_ui.py +225 -0
- intervalssync/gui/secrets.py +135 -0
- intervalssync/gui/settings_view.py +716 -0
- intervalssync/gui/support_gamification.py +658 -0
- intervalssync/gui/sync_view.py +486 -0
- intervalssync/gui/system.py +21 -0
- intervalssync/gui/theme.py +216 -0
- intervalssync/gui/update_check.py +55 -0
- intervalssync/igpsport/__init__.py +1 -0
- intervalssync/igpsport/core.py +520 -0
- intervalssync/igpsport/interval_info.py +269 -0
- intervalssync/igpsport/profile_sync.py +391 -0
- intervalssync/igpsport/region.py +80 -0
- intervalssync/igpsport/workout.py +503 -0
- intervalssync/igpsport/zone_map.py +143 -0
- intervalssync/intervals_icu.py +201 -0
- intervalssync-0.9.3.dist-info/METADATA +179 -0
- intervalssync-0.9.3.dist-info/RECORD +39 -0
- intervalssync-0.9.3.dist-info/WHEEL +4 -0
- intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
- intervalssync-0.9.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Non-secret CLI settings persisted as JSON.
|
|
2
|
+
|
|
3
|
+
Secrets never live here — they are read from the Hermes profile .env file via
|
|
4
|
+
`env.py`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from platformdirs import user_config_dir, user_downloads_dir
|
|
14
|
+
|
|
15
|
+
APP_NAME = "intervalssync-cli"
|
|
16
|
+
|
|
17
|
+
CONFIG_DIR = Path(user_config_dir(APP_NAME, appauthor=False))
|
|
18
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_download_dir() -> str:
|
|
22
|
+
return str(Path(user_downloads_dir()) / "intervalssync-fit")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CliConfig:
|
|
27
|
+
# Optional override for the secrets .env file path.
|
|
28
|
+
env_file: str = ""
|
|
29
|
+
max_activities: int = 5
|
|
30
|
+
download_dir: str = field(default_factory=_default_download_dir)
|
|
31
|
+
delete_after_upload: bool = True
|
|
32
|
+
force_resync: bool = False
|
|
33
|
+
activity_type: str = ""
|
|
34
|
+
# Planned workouts: intervals.icu event id → iGPSPORT workoutId.
|
|
35
|
+
uploaded_workouts: dict[str, int] = field(default_factory=dict)
|
|
36
|
+
# Planned workouts: intervals.icu event id → Bryton FIT filename stem.
|
|
37
|
+
uploaded_bryton_workouts: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
# How many calendar days of planned workouts to upload (1 = today only).
|
|
39
|
+
workout_days_ahead: int = 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load() -> CliConfig:
|
|
43
|
+
"""Load CLI config from disk, falling back to defaults."""
|
|
44
|
+
data: dict = {}
|
|
45
|
+
if CONFIG_PATH.exists():
|
|
46
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
47
|
+
|
|
48
|
+
return CliConfig(**{k: v for k, v in data.items() if k in CliConfig.__annotations__})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save(config: CliConfig) -> None:
|
|
52
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
CONFIG_PATH.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")
|
intervalssync/cli/env.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Credential loading for the headless CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
ActivitySource = Literal["igpsport", "bryton"]
|
|
11
|
+
|
|
12
|
+
IGPSPORT_USER_KEY = "INTERVALSSYNC_IGPSPORT_USER"
|
|
13
|
+
IGPSPORT_PASSWORD_KEY = "INTERVALSSYNC_IGPSPORT_PASSWORD"
|
|
14
|
+
IGPSPORT_REGION_KEY = "INTERVALSSYNC_IGPSPORT_REGION"
|
|
15
|
+
BRYTON_EMAIL_KEY = "INTERVALSSYNC_BRYTON_EMAIL"
|
|
16
|
+
BRYTON_PASSWORD_KEY = "INTERVALSSYNC_BRYTON_PASSWORD"
|
|
17
|
+
INTERVALS_API_KEY_KEY = "INTERVALSSYNC_INTERVALS_API_KEY"
|
|
18
|
+
|
|
19
|
+
IGPSPORT_REQUIRED_KEYS = (
|
|
20
|
+
IGPSPORT_USER_KEY,
|
|
21
|
+
IGPSPORT_PASSWORD_KEY,
|
|
22
|
+
INTERVALS_API_KEY_KEY,
|
|
23
|
+
)
|
|
24
|
+
BRYTON_REQUIRED_KEYS = (
|
|
25
|
+
BRYTON_EMAIL_KEY,
|
|
26
|
+
BRYTON_PASSWORD_KEY,
|
|
27
|
+
INTERVALS_API_KEY_KEY,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_IGPSPORT_BODY = (
|
|
31
|
+
f" {IGPSPORT_USER_KEY}=<email>\n"
|
|
32
|
+
f" {IGPSPORT_PASSWORD_KEY}=<password>\n"
|
|
33
|
+
f" {INTERVALS_API_KEY_KEY}=<api-key>\n"
|
|
34
|
+
f" {IGPSPORT_REGION_KEY}=international # or china for app.igpsport.cn"
|
|
35
|
+
)
|
|
36
|
+
_BRYTON_BODY = (
|
|
37
|
+
f" {BRYTON_EMAIL_KEY}=<email>\n"
|
|
38
|
+
f" {BRYTON_PASSWORD_KEY}=<password>\n"
|
|
39
|
+
f" {INTERVALS_API_KEY_KEY}=<api-key>"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
ENV_OVERRIDE_HINT = (
|
|
43
|
+
"Point intervalssync at a different file:\n"
|
|
44
|
+
" intervalssync <command> --env-file /path/to/.env\n"
|
|
45
|
+
"You can also set env_file in the intervalssync-cli config.json."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
SETUP_HINT_IGPSPORT = (
|
|
49
|
+
"Add the required keys to your profile .env (see docs/AGENT.md):\n"
|
|
50
|
+
f" echo '{IGPSPORT_USER_KEY}=<email>' >> {{profile_home}}/.env\n"
|
|
51
|
+
f" echo '{IGPSPORT_PASSWORD_KEY}=<password>' >> {{profile_home}}/.env\n"
|
|
52
|
+
f" echo '{INTERVALS_API_KEY_KEY}=<api-key>' >> {{profile_home}}/.env"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
SETUP_HINT_BRYTON = (
|
|
56
|
+
"Add the required keys to your profile .env (see docs/AGENT.md):\n"
|
|
57
|
+
f" echo '{BRYTON_EMAIL_KEY}=<email>' >> {{profile_home}}/.env\n"
|
|
58
|
+
f" echo '{BRYTON_PASSWORD_KEY}=<password>' >> {{profile_home}}/.env\n"
|
|
59
|
+
f" echo '{INTERVALS_API_KEY_KEY}=<api-key>' >> {{profile_home}}/.env"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CliConfigError(Exception):
|
|
64
|
+
"""Missing or invalid CLI credentials / env file."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class IgpsportCredentials:
|
|
69
|
+
igp_user: str
|
|
70
|
+
igp_password: str
|
|
71
|
+
intervals_api_key: str
|
|
72
|
+
igp_region: str = "international"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class BrytonCredentials:
|
|
77
|
+
bryton_email: str
|
|
78
|
+
bryton_password: str
|
|
79
|
+
intervals_api_key: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _default_hermes_env_path() -> Path:
|
|
83
|
+
hermes_home = os.environ.get("HERMES_HOME")
|
|
84
|
+
if hermes_home:
|
|
85
|
+
return Path(hermes_home) / ".env"
|
|
86
|
+
return Path.home() / ".hermes" / ".env"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_env_path(
|
|
90
|
+
*,
|
|
91
|
+
env_file: Path | None = None,
|
|
92
|
+
config_env_file: str | None = None,
|
|
93
|
+
) -> Path:
|
|
94
|
+
"""Resolve the secrets .env path (first match wins)."""
|
|
95
|
+
if env_file is not None:
|
|
96
|
+
return env_file.expanduser()
|
|
97
|
+
if config_env_file:
|
|
98
|
+
return Path(config_env_file).expanduser()
|
|
99
|
+
return _default_hermes_env_path()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_dotenv(text: str) -> dict[str, str]:
|
|
103
|
+
"""Parse KEY=value lines from a .env file (stdlib only)."""
|
|
104
|
+
values: dict[str, str] = {}
|
|
105
|
+
for raw_line in text.splitlines():
|
|
106
|
+
line = raw_line.strip()
|
|
107
|
+
if not line or line.startswith("#"):
|
|
108
|
+
continue
|
|
109
|
+
if line.startswith("export "):
|
|
110
|
+
line = line[len("export ") :].strip()
|
|
111
|
+
if "=" not in line:
|
|
112
|
+
continue
|
|
113
|
+
key, _, value = line.partition("=")
|
|
114
|
+
key = key.strip()
|
|
115
|
+
value = value.strip()
|
|
116
|
+
if not key:
|
|
117
|
+
continue
|
|
118
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
|
119
|
+
value = value[1:-1]
|
|
120
|
+
values[key] = value
|
|
121
|
+
return values
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _secrets_file_not_found_message(env_path: Path) -> str:
|
|
125
|
+
return (
|
|
126
|
+
f"Secrets file not found: {env_path}\n\n"
|
|
127
|
+
f"Create that file with iGPSPORT keys:\n{_IGPSPORT_BODY}\n\n"
|
|
128
|
+
f"or Bryton keys:\n{_BRYTON_BODY}\n\n"
|
|
129
|
+
f"{ENV_OVERRIDE_HINT}\n\n"
|
|
130
|
+
"For local development, copy .env.example to .env and pass --env-file .env."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _missing_keys_message(
|
|
135
|
+
env_path: Path, missing: list[str], *, source: ActivitySource
|
|
136
|
+
) -> str:
|
|
137
|
+
body = _IGPSPORT_BODY if source == "igpsport" else _BRYTON_BODY
|
|
138
|
+
hint = SETUP_HINT_IGPSPORT if source == "igpsport" else SETUP_HINT_BRYTON
|
|
139
|
+
return (
|
|
140
|
+
f"Missing required keys in {env_path}: {', '.join(missing)}\n\n"
|
|
141
|
+
f"Add them to the file:\n{body}\n\n{hint}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_igpsport_credentials(env_path: Path) -> IgpsportCredentials:
|
|
146
|
+
if not env_path.is_file():
|
|
147
|
+
raise CliConfigError(_secrets_file_not_found_message(env_path))
|
|
148
|
+
|
|
149
|
+
values = parse_dotenv(env_path.read_text(encoding="utf-8"))
|
|
150
|
+
missing = [key for key in IGPSPORT_REQUIRED_KEYS if not values.get(key)]
|
|
151
|
+
if missing:
|
|
152
|
+
raise CliConfigError(_missing_keys_message(env_path, missing, source="igpsport"))
|
|
153
|
+
|
|
154
|
+
region = values.get(IGPSPORT_REGION_KEY, "international") or "international"
|
|
155
|
+
if region not in ("international", "china"):
|
|
156
|
+
raise CliConfigError(
|
|
157
|
+
f"Invalid {IGPSPORT_REGION_KEY}={region!r} in {env_path} "
|
|
158
|
+
"(expected 'international' or 'china')."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return IgpsportCredentials(
|
|
162
|
+
igp_user=values[IGPSPORT_USER_KEY],
|
|
163
|
+
igp_password=values[IGPSPORT_PASSWORD_KEY],
|
|
164
|
+
intervals_api_key=values[INTERVALS_API_KEY_KEY],
|
|
165
|
+
igp_region=region,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def load_bryton_credentials(env_path: Path) -> BrytonCredentials:
|
|
170
|
+
if not env_path.is_file():
|
|
171
|
+
raise CliConfigError(_secrets_file_not_found_message(env_path))
|
|
172
|
+
|
|
173
|
+
values = parse_dotenv(env_path.read_text(encoding="utf-8"))
|
|
174
|
+
missing = [key for key in BRYTON_REQUIRED_KEYS if not values.get(key)]
|
|
175
|
+
if missing:
|
|
176
|
+
raise CliConfigError(_missing_keys_message(env_path, missing, source="bryton"))
|
|
177
|
+
|
|
178
|
+
return BrytonCredentials(
|
|
179
|
+
bryton_email=values[BRYTON_EMAIL_KEY],
|
|
180
|
+
bryton_password=values[BRYTON_PASSWORD_KEY],
|
|
181
|
+
intervals_api_key=values[INTERVALS_API_KEY_KEY],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def load_credentials(env_path: Path, source: ActivitySource = "igpsport"):
|
|
186
|
+
"""Load credentials for the given activity source."""
|
|
187
|
+
if source == "bryton":
|
|
188
|
+
return load_bryton_credentials(env_path)
|
|
189
|
+
return load_igpsport_credentials(env_path)
|