catence 0.2.1-beta.2 → 0.2.1-beta.21
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.
- package/README.md +4 -4
- package/config.example.json +1 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +16 -10
- package/dist/core/query/repository.d.ts +1 -1
- package/dist/core/query/repository.js +31 -2
- package/dist/core/retrieval/index.js +23 -5
- package/dist/core/runtime/athlete-file.d.ts +49 -0
- package/dist/core/runtime/athlete-file.js +177 -0
- package/dist/core/runtime/configuration.js +2 -0
- package/dist/core/runtime/console-accounts.d.ts +52 -0
- package/dist/core/runtime/console-accounts.js +103 -0
- package/dist/core/runtime/mcp-keys.d.ts +65 -0
- package/dist/core/runtime/mcp-keys.js +171 -0
- package/dist/core/runtime/oauth/model.d.ts +44 -0
- package/dist/core/runtime/oauth/model.js +345 -0
- package/dist/core/runtime/oauth/public-client.d.ts +28 -0
- package/dist/core/runtime/oauth/public-client.js +35 -0
- package/dist/core/runtime/oauth/settings.d.ts +33 -0
- package/dist/core/runtime/oauth/settings.js +96 -0
- package/dist/core/runtime/secrets.d.ts +15 -0
- package/dist/core/runtime/secrets.js +48 -7
- package/dist/elt/application/autosync.d.ts +71 -0
- package/dist/elt/application/autosync.js +173 -0
- package/dist/elt/application/detached-sync.d.ts +7 -2
- package/dist/elt/application/detached-sync.js +10 -4
- package/dist/elt/application/management.d.ts +7 -0
- package/dist/elt/application/management.js +103 -7
- package/dist/elt/ingestion/importer.d.ts +11 -1
- package/dist/elt/ingestion/importer.js +63 -14
- package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
- package/dist/elt/ingestion/providers/intervals.js +21 -2
- package/dist/elt/normalization/normalizers.js +122 -0
- package/dist/elt/storage/database.d.ts +6 -1
- package/dist/elt/storage/database.js +7 -1
- package/dist/elt/storage/migrations.js +45 -1
- package/dist/elt/storage/progress-sidecar.d.ts +9 -0
- package/dist/elt/storage/progress-sidecar.js +31 -16
- package/dist/interfaces/cli/commands.js +117 -2
- package/dist/interfaces/http/cli-options.d.ts +64 -1
- package/dist/interfaces/http/cli-options.js +214 -2
- package/dist/interfaces/http/oauth.d.ts +16 -0
- package/dist/interfaces/http/oauth.js +228 -0
- package/dist/interfaces/http/server.d.ts +60 -1
- package/dist/interfaces/http/server.js +810 -16
- package/dist/interfaces/mcp/main.js +35 -3
- package/dist/interfaces/mcp/server.d.ts +21 -1
- package/dist/interfaces/mcp/server.js +107 -21
- package/dist/runtime/index.d.ts +15 -3
- package/dist/runtime/index.js +9 -2
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +76 -6
- package/docs/deployment/docker.md +173 -15
- package/docs/deployment/local-console.md +132 -21
- package/docs/deployment/local-mcp.md +190 -11
- package/docs/development.md +5 -3
- package/docs/llm-providers.md +4 -4
- package/package.json +11 -4
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +55 -7
- package/python/catence/providers/garmin/progress.py +71 -35
- package/python/catence/providers/garmin/staging.py +11 -4
- package/python/catence/providers/garmin/worker.py +420 -206
- package/uv.lock +3 -3
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from concurrent import futures
|
|
3
4
|
from datetime import date, timedelta
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
import hashlib
|
|
6
7
|
import json
|
|
8
|
+
import random
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
7
11
|
from typing import Any, Callable, Mapping
|
|
8
12
|
|
|
9
13
|
from .progress import ProgressReporter
|
|
@@ -121,6 +125,11 @@ SCHEDULED_WORKOUTS_MONTHS_AHEAD = 12
|
|
|
121
125
|
# window predates this span).
|
|
122
126
|
EVENTS_PAST_DAYS = 30
|
|
123
127
|
|
|
128
|
+
# Garmin answers data-fetch bursts with HTTP 429. Back off cooperatively: a
|
|
129
|
+
# rate-limit hit parks every in-flight request until a jittered deadline, and
|
|
130
|
+
# the same call is retried on an escalating schedule before it fails.
|
|
131
|
+
RATE_LIMIT_BACKOFF_SECONDS = (5.0, 15.0, 45.0)
|
|
132
|
+
|
|
124
133
|
|
|
125
134
|
def shift_month(base: date, offset: int) -> date:
|
|
126
135
|
"""Return the first day of the month `offset` months from `base`'s month."""
|
|
@@ -150,13 +159,28 @@ def payload_date(payload: Mapping[str, Any]) -> str | None:
|
|
|
150
159
|
class GarminStagingWorker:
|
|
151
160
|
"""Extract Garmin data through the explicit read-only registry into JSONL/Parquet staging."""
|
|
152
161
|
|
|
153
|
-
def __init__(
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
api: Any,
|
|
165
|
+
writer: StagingWriter,
|
|
166
|
+
data_dir: Path,
|
|
167
|
+
known_activity_hashes: Mapping[str, str] | None = None,
|
|
168
|
+
progress: ProgressReporter | None = None,
|
|
169
|
+
concurrency: int = 1,
|
|
170
|
+
) -> None:
|
|
154
171
|
self.api = api
|
|
155
172
|
self.writer = writer
|
|
156
173
|
self.data_dir = data_dir
|
|
157
174
|
self.known_activity_hashes = dict(known_activity_hashes or {})
|
|
158
175
|
self.progress = progress
|
|
176
|
+
self.concurrency = max(1, concurrency)
|
|
159
177
|
self._interrupted = False
|
|
178
|
+
self._executor: futures.ThreadPoolExecutor | None = None
|
|
179
|
+
self._rate_limit_lock = threading.Lock()
|
|
180
|
+
self._rate_limit_until = 0.0
|
|
181
|
+
self._counts_lock = threading.Lock()
|
|
182
|
+
self._capture_counts: dict[str, int] = {}
|
|
183
|
+
self._capture_errors: dict[str, int] = {}
|
|
160
184
|
|
|
161
185
|
def request_interrupt(self) -> None:
|
|
162
186
|
"""Ask the worker to stop at the next safe boundary."""
|
|
@@ -168,6 +192,105 @@ class GarminStagingWorker:
|
|
|
168
192
|
self.progress.finish("interrupted")
|
|
169
193
|
raise WorkerInterrupted("Sync run interrupted between extraction steps")
|
|
170
194
|
|
|
195
|
+
def _wait_for_rate_limit(self) -> None:
|
|
196
|
+
"""Block until any shared 429 cooldown expires, staying interrupt-aware."""
|
|
197
|
+
while True:
|
|
198
|
+
with self._rate_limit_lock:
|
|
199
|
+
remaining = self._rate_limit_until - time.monotonic()
|
|
200
|
+
if remaining <= 0:
|
|
201
|
+
return
|
|
202
|
+
if self._interrupted:
|
|
203
|
+
self._check_interrupted()
|
|
204
|
+
time.sleep(min(0.25, remaining))
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _is_rate_limit(error: Exception) -> bool:
|
|
208
|
+
if "TooManyRequests" in type(error).__name__:
|
|
209
|
+
return True
|
|
210
|
+
if getattr(error, "status_code", None) == 429:
|
|
211
|
+
return True
|
|
212
|
+
try:
|
|
213
|
+
from garminconnect import GarminConnectTooManyRequestsError
|
|
214
|
+
except Exception: # garminconnect is optional in lightweight test contexts.
|
|
215
|
+
return False
|
|
216
|
+
return isinstance(error, GarminConnectTooManyRequestsError)
|
|
217
|
+
|
|
218
|
+
def _request(self, action: Callable[[], Any]) -> Any:
|
|
219
|
+
"""Run one Garmin API call with cooperative 429 handling."""
|
|
220
|
+
attempt = 0
|
|
221
|
+
while True:
|
|
222
|
+
self._wait_for_rate_limit()
|
|
223
|
+
try:
|
|
224
|
+
return action()
|
|
225
|
+
except Exception as error:
|
|
226
|
+
if not self._is_rate_limit(error) or attempt >= len(RATE_LIMIT_BACKOFF_SECONDS):
|
|
227
|
+
raise
|
|
228
|
+
delay = RATE_LIMIT_BACKOFF_SECONDS[attempt] * random.uniform(0.5, 1.5)
|
|
229
|
+
with self._rate_limit_lock:
|
|
230
|
+
self._rate_limit_until = max(self._rate_limit_until, time.monotonic() + delay)
|
|
231
|
+
attempt += 1
|
|
232
|
+
|
|
233
|
+
def _note_capture(self, endpoint: str, failed: bool) -> None:
|
|
234
|
+
with self._counts_lock:
|
|
235
|
+
target = self._capture_errors if failed else self._capture_counts
|
|
236
|
+
target[endpoint] = target.get(endpoint, 0) + 1
|
|
237
|
+
|
|
238
|
+
def capture_summary(self) -> dict[str, dict[str, int]]:
|
|
239
|
+
"""Per-endpoint capture counts for the CLI's stage-timing summary."""
|
|
240
|
+
with self._counts_lock:
|
|
241
|
+
return {"captures": dict(self._capture_counts), "captureErrors": dict(self._capture_errors)}
|
|
242
|
+
|
|
243
|
+
def _ensure_executor(self) -> futures.ThreadPoolExecutor:
|
|
244
|
+
if self._executor is None:
|
|
245
|
+
self._executor = futures.ThreadPoolExecutor(max_workers=self.concurrency, thread_name_prefix="garmin-sync")
|
|
246
|
+
return self._executor
|
|
247
|
+
|
|
248
|
+
def close(self) -> None:
|
|
249
|
+
"""Release the request pool once a sync run ends."""
|
|
250
|
+
executor, self._executor = self._executor, None
|
|
251
|
+
if executor is not None:
|
|
252
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
253
|
+
|
|
254
|
+
def _map(self, items: list[Any], worker: Callable[[Any], None], on_done: Callable[[], None] | None = None) -> None:
|
|
255
|
+
"""Run `worker` over `items`, bounded-parallel when configured.
|
|
256
|
+
|
|
257
|
+
The serial path (the default) preserves the original in-order request
|
|
258
|
+
flow exactly; concurrency > 1 keeps at most `concurrency` calls in
|
|
259
|
+
flight and reacts as soon as the first one completes.
|
|
260
|
+
"""
|
|
261
|
+
if self.concurrency <= 1:
|
|
262
|
+
for item in items:
|
|
263
|
+
self._check_interrupted()
|
|
264
|
+
worker(item)
|
|
265
|
+
if on_done is not None:
|
|
266
|
+
on_done()
|
|
267
|
+
return
|
|
268
|
+
executor = self._ensure_executor()
|
|
269
|
+
pending: set[futures.Future[None]] = set()
|
|
270
|
+
iterator = iter(items)
|
|
271
|
+
try:
|
|
272
|
+
while True:
|
|
273
|
+
while len(pending) < self.concurrency:
|
|
274
|
+
try:
|
|
275
|
+
item = next(iterator)
|
|
276
|
+
except StopIteration:
|
|
277
|
+
break
|
|
278
|
+
pending.add(executor.submit(worker, item))
|
|
279
|
+
if not pending:
|
|
280
|
+
return
|
|
281
|
+
done, _ = futures.wait(pending, timeout=0.5, return_when=futures.FIRST_COMPLETED)
|
|
282
|
+
for future in done:
|
|
283
|
+
pending.discard(future)
|
|
284
|
+
future.result()
|
|
285
|
+
if on_done is not None:
|
|
286
|
+
on_done()
|
|
287
|
+
if self._interrupted:
|
|
288
|
+
raise WorkerInterrupted("Sync run interrupted between extraction steps")
|
|
289
|
+
except BaseException:
|
|
290
|
+
for future in pending:
|
|
291
|
+
future.cancel()
|
|
292
|
+
raise
|
|
293
|
+
|
|
171
294
|
def sync(
|
|
172
295
|
self,
|
|
173
296
|
daily_from_date: date | None,
|
|
@@ -180,46 +303,53 @@ class GarminStagingWorker:
|
|
|
180
303
|
events_from: str | None = None,
|
|
181
304
|
) -> None:
|
|
182
305
|
assert_read_only_registry()
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
306
|
+
try:
|
|
307
|
+
end = to_date or date.today()
|
|
308
|
+
daily_end = daily_to_date or end
|
|
309
|
+
activity_end = activity_to_date or end
|
|
310
|
+
if include_non_historical:
|
|
311
|
+
self._check_interrupted()
|
|
312
|
+
self._singletons()
|
|
313
|
+
if daily_from_date and daily_from_date <= daily_end:
|
|
314
|
+
self._check_interrupted()
|
|
315
|
+
self._daily(daily_from_date, daily_end)
|
|
316
|
+
self._check_interrupted()
|
|
317
|
+
self._range(daily_from_date, daily_end)
|
|
318
|
+
self._check_interrupted()
|
|
319
|
+
self._cycling_ftp_history(daily_from_date, daily_end)
|
|
320
|
+
self._check_interrupted()
|
|
321
|
+
self._max_metrics_history(daily_from_date, daily_end)
|
|
322
|
+
self._check_interrupted()
|
|
323
|
+
self._hrv_history(daily_from_date, daily_end)
|
|
324
|
+
self._check_interrupted()
|
|
325
|
+
self._score_history(daily_from_date, daily_end)
|
|
326
|
+
# Lactate-threshold history uses the widest requested window so a
|
|
327
|
+
# single sync can catch stores up that were created before per-day
|
|
328
|
+
# LT capture existed; runs even when the daily window is skipped.
|
|
329
|
+
lt_windows = [window for window in (daily_from_date, lactate_threshold_history_from) if window is not None]
|
|
330
|
+
lt_start = min(lt_windows) if lt_windows else None
|
|
331
|
+
if lt_start is not None and lt_start <= daily_end:
|
|
332
|
+
self._check_interrupted()
|
|
333
|
+
self._lactate_threshold_history(lt_start, daily_end)
|
|
334
|
+
if activity_from_date and activity_from_date <= activity_end:
|
|
335
|
+
self._check_interrupted()
|
|
336
|
+
self._activities(activity_from_date, activity_end)
|
|
337
|
+
if include_non_historical:
|
|
338
|
+
self._check_interrupted()
|
|
339
|
+
self._collections(events_from=events_from)
|
|
340
|
+
finally:
|
|
341
|
+
self.close()
|
|
216
342
|
|
|
217
343
|
def _capture(self, endpoint: str, action: Callable[[], Any], remote_id_value: str | None = None, scope: dict[str, Any] | None = None) -> tuple[Any | None, str | None]:
|
|
218
344
|
try:
|
|
219
|
-
payload = action
|
|
345
|
+
payload = self._request(action)
|
|
220
346
|
raw_hash = self.writer.archive_json(endpoint, remote_id_value, payload, scope)
|
|
347
|
+
self._note_capture(endpoint, False)
|
|
221
348
|
return payload, raw_hash
|
|
349
|
+
except WorkerInterrupted:
|
|
350
|
+
raise
|
|
222
351
|
except Exception as error: # Provider availability is intentionally isolated per operation.
|
|
352
|
+
self._note_capture(endpoint, True)
|
|
223
353
|
self.writer.error(endpoint, str(error), remote_id_value)
|
|
224
354
|
return None, None
|
|
225
355
|
|
|
@@ -266,15 +396,23 @@ class GarminStagingWorker:
|
|
|
266
396
|
if self.progress is not None:
|
|
267
397
|
self.progress.set_stage("singletons")
|
|
268
398
|
self.progress.advance(total=len(calls))
|
|
269
|
-
|
|
270
|
-
|
|
399
|
+
completed = 0
|
|
400
|
+
|
|
401
|
+
def done() -> None:
|
|
402
|
+
nonlocal completed
|
|
403
|
+
completed += 1
|
|
404
|
+
if self.progress is not None:
|
|
405
|
+
self.progress.advance(completed=completed)
|
|
406
|
+
|
|
407
|
+
def fetch(item: tuple[str, tuple[str, str]]) -> None:
|
|
408
|
+
endpoint, (method, entity_type) = item
|
|
271
409
|
if self.progress is not None:
|
|
272
410
|
self.progress.advance(step=endpoint)
|
|
273
411
|
payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)())
|
|
274
412
|
if payload is not None:
|
|
275
413
|
self._entity(endpoint, entity_type, payload, raw_hash)
|
|
276
|
-
|
|
277
|
-
|
|
414
|
+
|
|
415
|
+
self._map(list(calls.items()), fetch, done)
|
|
278
416
|
|
|
279
417
|
def _daily(self, from_date: date, to_date: date) -> None:
|
|
280
418
|
calls = {
|
|
@@ -292,27 +430,33 @@ class GarminStagingWorker:
|
|
|
292
430
|
"nutrition_food_log": ("get_nutrition_daily_food_log", "nutrition_log"), "nutrition_meals": ("get_nutrition_daily_meals", "nutrition_log"),
|
|
293
431
|
"nutrition_settings": ("get_nutrition_daily_settings", "nutrition_setting"),
|
|
294
432
|
}
|
|
295
|
-
|
|
296
|
-
if self.progress is not None:
|
|
297
|
-
self.progress.set_stage("daily")
|
|
298
|
-
self.progress.advance(total=total_days)
|
|
433
|
+
tasks: list[tuple[str, str, str, str]] = []
|
|
299
434
|
current = from_date
|
|
300
|
-
completed_days = 0
|
|
301
435
|
while current <= to_date:
|
|
302
|
-
self._check_interrupted()
|
|
303
436
|
day = current.isoformat()
|
|
304
|
-
if self.progress is not None:
|
|
305
|
-
self.progress.advance(step=day)
|
|
306
437
|
for endpoint, (method, entity_type) in calls.items():
|
|
307
|
-
|
|
308
|
-
self.progress.advance(step=f"{day} {endpoint}")
|
|
309
|
-
payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
|
|
310
|
-
if payload is not None:
|
|
311
|
-
self._entity(endpoint, entity_type, payload, raw_hash, day)
|
|
312
|
-
completed_days += 1
|
|
313
|
-
if self.progress is not None:
|
|
314
|
-
self.progress.advance(completed=completed_days)
|
|
438
|
+
tasks.append((day, endpoint, method, entity_type))
|
|
315
439
|
current += timedelta(days=1)
|
|
440
|
+
if self.progress is not None:
|
|
441
|
+
self.progress.set_stage("daily")
|
|
442
|
+
self.progress.advance(total=len(tasks))
|
|
443
|
+
completed = 0
|
|
444
|
+
|
|
445
|
+
def done() -> None:
|
|
446
|
+
nonlocal completed
|
|
447
|
+
completed += 1
|
|
448
|
+
if self.progress is not None:
|
|
449
|
+
self.progress.advance(completed=completed)
|
|
450
|
+
|
|
451
|
+
def fetch(task: tuple[str, str, str, str]) -> None:
|
|
452
|
+
day, endpoint, method, entity_type = task
|
|
453
|
+
if self.progress is not None:
|
|
454
|
+
self.progress.advance(step=f"{day} {endpoint}")
|
|
455
|
+
payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
|
|
456
|
+
if payload is not None:
|
|
457
|
+
self._entity(endpoint, entity_type, payload, raw_hash, day)
|
|
458
|
+
|
|
459
|
+
self._map(tasks, fetch, done)
|
|
316
460
|
|
|
317
461
|
def _range(self, from_date: date, to_date: date) -> None:
|
|
318
462
|
calls = {
|
|
@@ -322,8 +466,8 @@ class GarminStagingWorker:
|
|
|
322
466
|
}
|
|
323
467
|
if self.progress is not None:
|
|
324
468
|
self.progress.set_stage("range")
|
|
325
|
-
|
|
326
|
-
for
|
|
469
|
+
tasks: list[tuple[str, str, str, str, str]] = []
|
|
470
|
+
for endpoint, (method, entity_type) in calls.items():
|
|
327
471
|
# Garmin rejects broad body-battery and menstrual-calendar ranges.
|
|
328
472
|
# Keep every request inside the provider's 92-day limit.
|
|
329
473
|
window_start = from_date
|
|
@@ -331,34 +475,54 @@ class GarminStagingWorker:
|
|
|
331
475
|
# ranges. Keep it deliberately small; the captures are
|
|
332
476
|
# content-addressed so overlapping retry windows are harmless.
|
|
333
477
|
window_days = 7 if endpoint == "body_battery" else (90 if endpoint == "menstrual_calendar" else (to_date - from_date).days + 1)
|
|
334
|
-
if self.progress is not None:
|
|
335
|
-
self.progress.advance(step=endpoint)
|
|
336
478
|
while window_start <= to_date:
|
|
337
|
-
self._check_interrupted()
|
|
338
479
|
window_end = min(to_date, window_start + timedelta(days=window_days - 1))
|
|
339
|
-
|
|
340
|
-
payload, raw_hash = self._capture(endpoint, lambda method=method, start=start, end=end: getattr(self.api, method)(start, end), None, {"from": start, "to": end})
|
|
341
|
-
if payload is not None:
|
|
342
|
-
self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
|
|
480
|
+
tasks.append((endpoint, method, entity_type, window_start.isoformat(), window_end.isoformat()))
|
|
343
481
|
window_start = window_end + timedelta(days=1)
|
|
482
|
+
if self.progress is not None:
|
|
483
|
+
self.progress.advance(total=len(tasks))
|
|
484
|
+
completed = 0
|
|
485
|
+
|
|
486
|
+
def done() -> None:
|
|
487
|
+
nonlocal completed
|
|
488
|
+
completed += 1
|
|
344
489
|
if self.progress is not None:
|
|
345
|
-
self.progress.advance(completed=
|
|
490
|
+
self.progress.advance(completed=completed)
|
|
491
|
+
|
|
492
|
+
def fetch(task: tuple[str, str, str, str, str]) -> None:
|
|
493
|
+
endpoint, method, entity_type, start, end = task
|
|
494
|
+
if self.progress is not None:
|
|
495
|
+
self.progress.advance(step=endpoint)
|
|
496
|
+
payload, raw_hash = self._capture(endpoint, lambda method=method, start=start, end=end: getattr(self.api, method)(start, end), None, {"from": start, "to": end})
|
|
497
|
+
if payload is not None:
|
|
498
|
+
self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
|
|
499
|
+
|
|
500
|
+
self._map(tasks, fetch, done)
|
|
346
501
|
|
|
347
502
|
def _cycling_ftp_history(self, from_date: date, to_date: date) -> None:
|
|
348
503
|
"""Stage daily cycling FTP setting history from Garmin's range endpoint."""
|
|
349
504
|
endpoint = "functional_threshold_power_range"
|
|
505
|
+
windows: list[tuple[str, str]] = []
|
|
350
506
|
window_start = from_date
|
|
351
|
-
total_windows = (to_date - from_date).days // 89 + 1
|
|
352
|
-
if self.progress is not None:
|
|
353
|
-
self.progress.set_stage("ftp_history")
|
|
354
|
-
self.progress.advance(total=total_windows)
|
|
355
|
-
completed_windows = 0
|
|
356
507
|
while window_start <= to_date:
|
|
357
|
-
self._check_interrupted()
|
|
358
508
|
# This private Connect endpoint is not documented with a maximum
|
|
359
509
|
# range. Keep requests aligned with other conservative range calls.
|
|
360
510
|
window_end = min(to_date, window_start + timedelta(days=89))
|
|
361
|
-
|
|
511
|
+
windows.append((window_start.isoformat(), window_end.isoformat()))
|
|
512
|
+
window_start = window_end + timedelta(days=1)
|
|
513
|
+
if self.progress is not None:
|
|
514
|
+
self.progress.set_stage("ftp_history")
|
|
515
|
+
self.progress.advance(total=len(windows))
|
|
516
|
+
completed = 0
|
|
517
|
+
|
|
518
|
+
def done() -> None:
|
|
519
|
+
nonlocal completed
|
|
520
|
+
completed += 1
|
|
521
|
+
if self.progress is not None:
|
|
522
|
+
self.progress.advance(completed=completed)
|
|
523
|
+
|
|
524
|
+
def fetch(window: tuple[str, str]) -> None:
|
|
525
|
+
start, end = window
|
|
362
526
|
if self.progress is not None:
|
|
363
527
|
self.progress.advance(step=start)
|
|
364
528
|
payload, raw_hash = self._capture(
|
|
@@ -389,24 +553,31 @@ class GarminStagingWorker:
|
|
|
389
553
|
raw_hash,
|
|
390
554
|
occurred_on=occurred_on,
|
|
391
555
|
)
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
self.progress.advance(completed=completed_windows)
|
|
395
|
-
window_start = window_end + timedelta(days=1)
|
|
556
|
+
|
|
557
|
+
self._map(windows, fetch, done)
|
|
396
558
|
|
|
397
559
|
def _lactate_threshold_history(self, from_date: date, to_date: date) -> None:
|
|
398
560
|
"""Stage daily running lactate-threshold history from Garmin's range endpoints."""
|
|
561
|
+
windows: list[tuple[str, str]] = []
|
|
399
562
|
window_start = from_date
|
|
400
|
-
total_windows = (to_date - from_date).days // 365 + 1
|
|
401
|
-
if self.progress is not None:
|
|
402
|
-
self.progress.set_stage("lt_history")
|
|
403
|
-
self.progress.advance(total=total_windows)
|
|
404
|
-
completed_windows = 0
|
|
405
563
|
while window_start <= to_date:
|
|
406
|
-
self._check_interrupted()
|
|
407
564
|
# Garmin rejects range windows longer than 366 days.
|
|
408
565
|
window_end = min(to_date, window_start + timedelta(days=365))
|
|
409
|
-
|
|
566
|
+
windows.append((window_start.isoformat(), window_end.isoformat()))
|
|
567
|
+
window_start = window_end + timedelta(days=1)
|
|
568
|
+
if self.progress is not None:
|
|
569
|
+
self.progress.set_stage("lt_history")
|
|
570
|
+
self.progress.advance(total=len(windows))
|
|
571
|
+
completed = 0
|
|
572
|
+
|
|
573
|
+
def done() -> None:
|
|
574
|
+
nonlocal completed
|
|
575
|
+
completed += 1
|
|
576
|
+
if self.progress is not None:
|
|
577
|
+
self.progress.advance(completed=completed)
|
|
578
|
+
|
|
579
|
+
def fetch(window: tuple[str, str]) -> None:
|
|
580
|
+
start, end = window
|
|
410
581
|
if self.progress is not None:
|
|
411
582
|
self.progress.advance(step=start)
|
|
412
583
|
payload, raw_hash = self._capture(
|
|
@@ -424,23 +595,31 @@ class GarminStagingWorker:
|
|
|
424
595
|
raw_hash,
|
|
425
596
|
occurred_on=item_day,
|
|
426
597
|
)
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
self.progress.advance(completed=completed_windows)
|
|
430
|
-
window_start = window_end + timedelta(days=1)
|
|
598
|
+
|
|
599
|
+
self._map(windows, fetch, done)
|
|
431
600
|
|
|
432
601
|
def _max_metrics_history(self, from_date: date, to_date: date) -> None:
|
|
433
602
|
"""Stage range max-metric responses by their actual calendar date."""
|
|
603
|
+
windows: list[tuple[str, str]] = []
|
|
434
604
|
window_start = from_date
|
|
435
|
-
total_windows = (to_date - from_date).days // 89 + 1
|
|
436
|
-
if self.progress is not None:
|
|
437
|
-
self.progress.set_stage("max_metrics")
|
|
438
|
-
self.progress.advance(total=total_windows)
|
|
439
|
-
completed_windows = 0
|
|
440
605
|
while window_start <= to_date:
|
|
441
|
-
self._check_interrupted()
|
|
442
606
|
window_end = min(to_date, window_start + timedelta(days=89))
|
|
443
|
-
|
|
607
|
+
windows.append((window_start.isoformat(), window_end.isoformat()))
|
|
608
|
+
window_start = window_end + timedelta(days=1)
|
|
609
|
+
if self.progress is not None:
|
|
610
|
+
self.progress.set_stage("max_metrics")
|
|
611
|
+
self.progress.advance(total=len(windows))
|
|
612
|
+
completed = 0
|
|
613
|
+
observed_by_window: list[set[str]] = [set() for _ in windows]
|
|
614
|
+
|
|
615
|
+
def done() -> None:
|
|
616
|
+
nonlocal completed
|
|
617
|
+
completed += 1
|
|
618
|
+
if self.progress is not None:
|
|
619
|
+
self.progress.advance(completed=completed)
|
|
620
|
+
|
|
621
|
+
def fetch_range(task: tuple[int, tuple[str, str]]) -> None:
|
|
622
|
+
index, (start, end) = task
|
|
444
623
|
if self.progress is not None:
|
|
445
624
|
self.progress.advance(step=start)
|
|
446
625
|
payload, raw_hash = self._capture(
|
|
@@ -449,44 +628,64 @@ class GarminStagingWorker:
|
|
|
449
628
|
None,
|
|
450
629
|
{"from": start, "to": end},
|
|
451
630
|
)
|
|
452
|
-
observed: set[str] = set()
|
|
453
631
|
if payload is not None:
|
|
454
632
|
for item in as_items(payload):
|
|
455
633
|
item_day = payload_date(item)
|
|
456
634
|
if item_day:
|
|
457
|
-
|
|
635
|
+
observed_by_window[index].add(item_day)
|
|
458
636
|
self.writer.source_entity("max_metric", f"max_metrics:{item_day}", item, raw_hash, occurred_on=item_day)
|
|
459
|
-
|
|
637
|
+
|
|
638
|
+
self._map(list(enumerate(windows)), fetch_range, done)
|
|
639
|
+
|
|
640
|
+
# Days the provider omitted from a range response are retried one by
|
|
641
|
+
# one; a missing range day is authoritative for that window only.
|
|
642
|
+
fallback_days: list[str] = []
|
|
643
|
+
for (start, end), observed in zip(windows, observed_by_window):
|
|
644
|
+
current = date.fromisoformat(start)
|
|
645
|
+
window_end = date.fromisoformat(end)
|
|
460
646
|
while current <= window_end:
|
|
461
647
|
day = current.isoformat()
|
|
462
648
|
if day not in observed:
|
|
463
|
-
|
|
464
|
-
"max_metrics",
|
|
465
|
-
lambda day=day: self.api.get_max_metrics(day),
|
|
466
|
-
day,
|
|
467
|
-
{"date": day, "fallback": "range-missing"},
|
|
468
|
-
)
|
|
469
|
-
if fallback is not None:
|
|
470
|
-
for item in as_items(fallback):
|
|
471
|
-
self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
|
|
649
|
+
fallback_days.append(day)
|
|
472
650
|
current += timedelta(days=1)
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
651
|
+
if self.progress is not None:
|
|
652
|
+
self.progress.advance(total=len(windows) + len(fallback_days))
|
|
653
|
+
|
|
654
|
+
def fetch_fallback(day: str) -> None:
|
|
655
|
+
fallback, fallback_hash = self._capture(
|
|
656
|
+
"max_metrics",
|
|
657
|
+
lambda day=day: self.api.get_max_metrics(day),
|
|
658
|
+
day,
|
|
659
|
+
{"date": day, "fallback": "range-missing"},
|
|
660
|
+
)
|
|
661
|
+
if fallback is not None:
|
|
662
|
+
for item in as_items(fallback):
|
|
663
|
+
self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
|
|
664
|
+
|
|
665
|
+
self._map(fallback_days, fetch_fallback, done)
|
|
477
666
|
|
|
478
667
|
def _hrv_history(self, from_date: date, to_date: date) -> None:
|
|
479
668
|
"""Stage the PR-402 HRV range API, retaining daily fallback coverage."""
|
|
669
|
+
windows: list[tuple[str, str]] = []
|
|
480
670
|
window_start = from_date
|
|
481
|
-
total_windows = (to_date - from_date).days // 89 + 1
|
|
482
|
-
if self.progress is not None:
|
|
483
|
-
self.progress.set_stage("hrv_history")
|
|
484
|
-
self.progress.advance(total=total_windows)
|
|
485
|
-
completed_windows = 0
|
|
486
671
|
while window_start <= to_date:
|
|
487
|
-
self._check_interrupted()
|
|
488
672
|
window_end = min(to_date, window_start + timedelta(days=89))
|
|
489
|
-
|
|
673
|
+
windows.append((window_start.isoformat(), window_end.isoformat()))
|
|
674
|
+
window_start = window_end + timedelta(days=1)
|
|
675
|
+
if self.progress is not None:
|
|
676
|
+
self.progress.set_stage("hrv_history")
|
|
677
|
+
self.progress.advance(total=len(windows))
|
|
678
|
+
completed = 0
|
|
679
|
+
observed_by_window: list[set[str]] = [set() for _ in windows]
|
|
680
|
+
|
|
681
|
+
def done() -> None:
|
|
682
|
+
nonlocal completed
|
|
683
|
+
completed += 1
|
|
684
|
+
if self.progress is not None:
|
|
685
|
+
self.progress.advance(completed=completed)
|
|
686
|
+
|
|
687
|
+
def fetch_range(task: tuple[int, tuple[str, str]]) -> None:
|
|
688
|
+
index, (start, end) = task
|
|
490
689
|
if self.progress is not None:
|
|
491
690
|
self.progress.advance(step=start)
|
|
492
691
|
payload, raw_hash = self._capture(
|
|
@@ -495,65 +694,85 @@ class GarminStagingWorker:
|
|
|
495
694
|
None,
|
|
496
695
|
{"from": start, "to": end},
|
|
497
696
|
)
|
|
498
|
-
observed: set[str] = set()
|
|
499
697
|
if payload is not None:
|
|
500
698
|
for item in as_items(payload):
|
|
501
699
|
item_day = payload_date(item)
|
|
502
700
|
if item_day:
|
|
503
|
-
|
|
701
|
+
observed_by_window[index].add(item_day)
|
|
504
702
|
self.writer.source_entity("daily_health", f"hrv:{item_day}", item, raw_hash, occurred_on=item_day)
|
|
505
|
-
|
|
703
|
+
|
|
704
|
+
self._map(list(enumerate(windows)), fetch_range, done)
|
|
705
|
+
|
|
706
|
+
fallback_days: list[str] = []
|
|
707
|
+
for (start, end), observed in zip(windows, observed_by_window):
|
|
708
|
+
current = date.fromisoformat(start)
|
|
709
|
+
window_end = date.fromisoformat(end)
|
|
506
710
|
while current <= window_end:
|
|
507
711
|
day = current.isoformat()
|
|
508
712
|
if day not in observed:
|
|
509
|
-
|
|
510
|
-
if fallback is not None:
|
|
511
|
-
self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
|
|
713
|
+
fallback_days.append(day)
|
|
512
714
|
current += timedelta(days=1)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
715
|
+
if self.progress is not None:
|
|
716
|
+
self.progress.advance(total=len(windows) + len(fallback_days))
|
|
717
|
+
|
|
718
|
+
def fetch_fallback(day: str) -> None:
|
|
719
|
+
fallback, fallback_hash = self._capture("hrv", lambda day=day: self.api.get_hrv_data(day), day, {"date": day, "fallback": "range-missing"})
|
|
720
|
+
if fallback is not None:
|
|
721
|
+
self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
|
|
722
|
+
|
|
723
|
+
self._map(fallback_days, fetch_fallback, done)
|
|
517
724
|
|
|
518
725
|
def _score_history(self, from_date: date, to_date: date) -> None:
|
|
519
726
|
"""Stage daily-capable endurance-adjacent metrics from range endpoints."""
|
|
727
|
+
windows: list[tuple[str, str]] = []
|
|
520
728
|
window_start = from_date
|
|
521
|
-
total_windows = (to_date - from_date).days // 89 + 1
|
|
522
|
-
if self.progress is not None:
|
|
523
|
-
self.progress.set_stage("scores")
|
|
524
|
-
self.progress.advance(total=total_windows)
|
|
525
|
-
completed_windows = 0
|
|
526
729
|
while window_start <= to_date:
|
|
527
|
-
self._check_interrupted()
|
|
528
730
|
window_end = min(to_date, window_start + timedelta(days=89))
|
|
529
|
-
|
|
731
|
+
windows.append((window_start.isoformat(), window_end.isoformat()))
|
|
732
|
+
window_start = window_end + timedelta(days=1)
|
|
733
|
+
tasks = [(endpoint, start, end) for start, end in windows for endpoint in ("hill_score", "running_tolerance")]
|
|
734
|
+
if self.progress is not None:
|
|
735
|
+
self.progress.set_stage("scores")
|
|
736
|
+
self.progress.advance(total=len(tasks))
|
|
737
|
+
completed = 0
|
|
738
|
+
|
|
739
|
+
def done() -> None:
|
|
740
|
+
nonlocal completed
|
|
741
|
+
completed += 1
|
|
530
742
|
if self.progress is not None:
|
|
531
|
-
self.progress.advance(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
):
|
|
536
|
-
payload, raw_hash = self._capture(endpoint, method, None, {"from": start, "to": end, "aggregation": "daily"})
|
|
537
|
-
if payload is not None:
|
|
538
|
-
for index, item in enumerate(as_items(payload)):
|
|
539
|
-
item_day = payload_date(item)
|
|
540
|
-
if item_day:
|
|
541
|
-
self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
|
|
542
|
-
completed_windows += 1
|
|
743
|
+
self.progress.advance(completed=completed)
|
|
744
|
+
|
|
745
|
+
def fetch(task: tuple[str, str, str]) -> None:
|
|
746
|
+
endpoint, start, end = task
|
|
543
747
|
if self.progress is not None:
|
|
544
|
-
self.progress.advance(
|
|
545
|
-
|
|
748
|
+
self.progress.advance(step=start)
|
|
749
|
+
if endpoint == "hill_score":
|
|
750
|
+
method = lambda start=start, end=end: self.api.get_hill_score(start, end)
|
|
751
|
+
entity_type = "hill_score"
|
|
752
|
+
else:
|
|
753
|
+
method = lambda start=start, end=end: self.api.get_running_tolerance(start, end, aggregation="daily")
|
|
754
|
+
entity_type = "running_tolerance"
|
|
755
|
+
payload, raw_hash = self._capture(endpoint, method, None, {"from": start, "to": end, "aggregation": "daily"})
|
|
756
|
+
if payload is not None:
|
|
757
|
+
for index, item in enumerate(as_items(payload)):
|
|
758
|
+
item_day = payload_date(item)
|
|
759
|
+
if item_day:
|
|
760
|
+
self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
|
|
761
|
+
|
|
762
|
+
self._map(tasks, fetch, done)
|
|
546
763
|
|
|
547
764
|
# Garmin limits daily race-prediction windows to one year.
|
|
765
|
+
race_windows: list[tuple[str, str]] = []
|
|
548
766
|
window_start = from_date
|
|
549
|
-
total_windows = (to_date - from_date).days // 365 + 1
|
|
550
|
-
if self.progress is not None:
|
|
551
|
-
self.progress.advance(total=total_windows)
|
|
552
|
-
completed_windows = 0
|
|
553
767
|
while window_start <= to_date:
|
|
554
|
-
self._check_interrupted()
|
|
555
768
|
window_end = min(to_date, window_start + timedelta(days=365))
|
|
556
|
-
|
|
769
|
+
race_windows.append((window_start.isoformat(), window_end.isoformat()))
|
|
770
|
+
window_start = window_end + timedelta(days=1)
|
|
771
|
+
if self.progress is not None:
|
|
772
|
+
self.progress.advance(total=len(tasks) + len(race_windows))
|
|
773
|
+
|
|
774
|
+
def fetch_race(window: tuple[str, str]) -> None:
|
|
775
|
+
start, end = window
|
|
557
776
|
if self.progress is not None:
|
|
558
777
|
self.progress.advance(step=start)
|
|
559
778
|
payload, raw_hash = self._capture("race_predictions", lambda start=start, end=end: self.api.get_race_predictions(start, end, "daily"), None, {"from": start, "to": end, "aggregation": "daily"})
|
|
@@ -561,10 +780,8 @@ class GarminStagingWorker:
|
|
|
561
780
|
for index, item in enumerate(as_items(payload)):
|
|
562
781
|
item_day = payload_date(item) or start
|
|
563
782
|
self.writer.source_entity("race_prediction", f"race_prediction:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
self.progress.advance(completed=completed_windows)
|
|
567
|
-
window_start = window_end + timedelta(days=1)
|
|
783
|
+
|
|
784
|
+
self._map(race_windows, fetch_race, done)
|
|
568
785
|
|
|
569
786
|
def _activities(self, from_date: date, to_date: date) -> None:
|
|
570
787
|
start, end = from_date.isoformat(), to_date.isoformat()
|
|
@@ -577,8 +794,15 @@ class GarminStagingWorker:
|
|
|
577
794
|
summaries = as_items(payload)
|
|
578
795
|
if self.progress is not None:
|
|
579
796
|
self.progress.advance(total=len(summaries))
|
|
580
|
-
|
|
581
|
-
|
|
797
|
+
completed = 0
|
|
798
|
+
|
|
799
|
+
def done() -> None:
|
|
800
|
+
nonlocal completed
|
|
801
|
+
completed += 1
|
|
802
|
+
if self.progress is not None:
|
|
803
|
+
self.progress.advance(completed=completed)
|
|
804
|
+
|
|
805
|
+
def fetch(summary: dict[str, Any]) -> None:
|
|
582
806
|
activity_id = remote_id(summary, "unknown")
|
|
583
807
|
if self.progress is not None:
|
|
584
808
|
self.progress.advance(step=activity_id)
|
|
@@ -587,8 +811,8 @@ class GarminStagingWorker:
|
|
|
587
811
|
if details_fetched:
|
|
588
812
|
self._activity_details(activity_id, summary)
|
|
589
813
|
self.writer.activity_sync_state(activity_id, summary_hash, details_fetched)
|
|
590
|
-
|
|
591
|
-
|
|
814
|
+
|
|
815
|
+
self._map(summaries, fetch, done)
|
|
592
816
|
|
|
593
817
|
def _multisport_children(self, parent_activity_id: str, activity: dict[str, Any]) -> None:
|
|
594
818
|
if not activity.get("isMultiSportParent"):
|
|
@@ -669,27 +893,25 @@ class GarminStagingWorker:
|
|
|
669
893
|
def _activity_files(self, activity_id: str, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> None:
|
|
670
894
|
try:
|
|
671
895
|
from garminconnect import Garmin
|
|
672
|
-
|
|
673
|
-
"activity_original": Garmin.ActivityDownloadFormat.ORIGINAL,
|
|
674
|
-
"activity_tcx": Garmin.ActivityDownloadFormat.TCX,
|
|
675
|
-
"activity_gpx": Garmin.ActivityDownloadFormat.GPX,
|
|
676
|
-
"activity_kml": Garmin.ActivityDownloadFormat.KML,
|
|
677
|
-
"activity_csv": Garmin.ActivityDownloadFormat.CSV,
|
|
678
|
-
}
|
|
896
|
+
original_format = Garmin.ActivityDownloadFormat.ORIGINAL
|
|
679
897
|
except ImportError as error:
|
|
680
898
|
self.writer.error("activity_files", str(error), activity_id, False)
|
|
681
899
|
return
|
|
682
900
|
original_path: Path | None = None
|
|
683
901
|
original_hash: str | None = None
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
902
|
+
# Only the ORIGINAL archive (.FIT inside a zip) is retained: streams and
|
|
903
|
+
# swim lengths come from it, and the TCX/GPX/KML/CSV renderings add four
|
|
904
|
+
# requests per changed activity without feeding any importer path.
|
|
905
|
+
try:
|
|
906
|
+
contents = self._request(lambda: self.api.download_activity(activity_id, original_format))
|
|
907
|
+
original_hash = self.writer.archive_bytes("activity_original", activity_id, contents, "zip")
|
|
908
|
+
original_path = self.data_dir / "raw" / "garmin" / "activity_original" / activity_id / f"{original_hash}.zip"
|
|
909
|
+
self._note_capture("activity_original", False)
|
|
910
|
+
except WorkerInterrupted:
|
|
911
|
+
raise
|
|
912
|
+
except Exception as error:
|
|
913
|
+
self._note_capture("activity_original", True)
|
|
914
|
+
self.writer.error("activity_original", str(error), activity_id)
|
|
693
915
|
samples = activity_details_to_samples(f"garmin:{activity_id}", details or {})
|
|
694
916
|
if not samples and original_path and original_path.exists():
|
|
695
917
|
samples = fit_archive_to_samples(f"garmin:{activity_id}", original_path)
|
|
@@ -725,22 +947,6 @@ class GarminStagingWorker:
|
|
|
725
947
|
occurred_on=start_date[:10],
|
|
726
948
|
)
|
|
727
949
|
|
|
728
|
-
def _fetch_garmin_events(self, start_date: str) -> None:
|
|
729
|
-
"""Fetch calendar events from start_date, following pagination."""
|
|
730
|
-
page = 1
|
|
731
|
-
while True:
|
|
732
|
-
payload, raw_hash = self._capture(
|
|
733
|
-
"garmin_events",
|
|
734
|
-
lambda page=page: self.api.get_events(start_date, limit=100, page=page),
|
|
735
|
-
scope={"startDate": start_date, "page": page},
|
|
736
|
-
)
|
|
737
|
-
items = payload if isinstance(payload, list) else []
|
|
738
|
-
if items:
|
|
739
|
-
self._entity("garmin_events", "event", items, raw_hash)
|
|
740
|
-
if len(items) < 100:
|
|
741
|
-
return
|
|
742
|
-
page += 1
|
|
743
|
-
|
|
744
950
|
def _collections(self, events_from: str | None = None) -> None:
|
|
745
951
|
today = date.today()
|
|
746
952
|
calls = {
|
|
@@ -752,23 +958,30 @@ class GarminStagingWorker:
|
|
|
752
958
|
if self.progress is not None:
|
|
753
959
|
self.progress.set_stage("collections")
|
|
754
960
|
self.progress.advance(total=len(calls) + len(months) + 1)
|
|
755
|
-
|
|
756
|
-
|
|
961
|
+
completed = 0
|
|
962
|
+
|
|
963
|
+
def done() -> None:
|
|
964
|
+
nonlocal completed
|
|
965
|
+
completed += 1
|
|
966
|
+
if self.progress is not None:
|
|
967
|
+
self.progress.advance(completed=completed)
|
|
968
|
+
|
|
969
|
+
def fetch_call(item: tuple[str, tuple[str, tuple[Any, ...], str]]) -> None:
|
|
970
|
+
endpoint, (method, arguments, entity_type) = item
|
|
757
971
|
if self.progress is not None:
|
|
758
972
|
self.progress.advance(step=endpoint)
|
|
759
973
|
payload, raw_hash = self._capture(endpoint, lambda method=method, arguments=arguments: getattr(self.api, method)(*arguments))
|
|
760
974
|
if payload is not None:
|
|
761
975
|
self._entity(endpoint, entity_type, payload, raw_hash)
|
|
762
|
-
|
|
763
|
-
|
|
976
|
+
|
|
977
|
+
self._map(list(calls.items()), fetch_call, done)
|
|
764
978
|
# Scheduled workouts span past-to-future months so upcoming sessions
|
|
765
979
|
# surface ahead of time and recently completed ones stay associatable
|
|
766
980
|
# with their finished activities. The calendar service answers each
|
|
767
981
|
# month with a grid wrapper whose items live under `calendarItems`;
|
|
768
982
|
# empty months must stage nothing, and real items stage one row each
|
|
769
983
|
# with their own calendar date.
|
|
770
|
-
|
|
771
|
-
self._check_interrupted()
|
|
984
|
+
def fetch_month(month: date) -> None:
|
|
772
985
|
payload, raw_hash = self._capture(
|
|
773
986
|
"scheduled_workouts",
|
|
774
987
|
lambda month=month: self.api.get_scheduled_workouts(month.year, month.month),
|
|
@@ -782,17 +995,18 @@ class GarminStagingWorker:
|
|
|
782
995
|
items = [item for item in items if isinstance(item, Mapping) and item.get("itemType") == "workout"]
|
|
783
996
|
if items:
|
|
784
997
|
self._entity("scheduled_workouts", "scheduled_workout", items, raw_hash)
|
|
785
|
-
|
|
786
|
-
|
|
998
|
+
|
|
999
|
+
self._map(months, fetch_month, done)
|
|
787
1000
|
# Joined events (races etc.) look back only far enough to associate
|
|
788
1001
|
# finished activities; the parent widens start_date into a one-time
|
|
789
1002
|
# coverage backfill when the store predates this span.
|
|
790
|
-
self._check_interrupted()
|
|
791
1003
|
if self.progress is not None:
|
|
792
1004
|
self.progress.advance(step="garmin_events")
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
self.
|
|
1005
|
+
|
|
1006
|
+
def fetch_events(_: str) -> None:
|
|
1007
|
+
self._fetch_garmin_events(events_start)
|
|
1008
|
+
|
|
1009
|
+
self._map([events_start], fetch_events, done)
|
|
796
1010
|
|
|
797
1011
|
def stage_course(self, course_id: str) -> None:
|
|
798
1012
|
"""Fetch one Garmin course by id and stage it as a source entity.
|