trainerroad-cli 0.1.1 → 0.3.0
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/CHANGELOG.md +10 -0
- package/README.md +60 -1
- package/package.json +13 -1
- package/src/cli.mjs +121 -21
- package/src/commands/auth.mjs +3 -2
- package/src/commands/discovery.mjs +9 -8
- package/src/commands/train-now.mjs +142 -0
- package/src/commands/workout-library.mjs +430 -0
- package/src/commands/workout-mutations.mjs +310 -0
- package/src/commands/workout-recommend.mjs +175 -0
- package/src/commands/workout-tools.mjs +388 -0
- package/src/lib/command-manifest.mjs +465 -25
- package/src/trainerroad-client.mjs +295 -1
|
@@ -12,6 +12,18 @@ function ensureLeadingSlash(value) {
|
|
|
12
12
|
return value;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function toApiDateOnly(value) {
|
|
16
|
+
const match = String(value ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
17
|
+
if (!match) {
|
|
18
|
+
throw new Error(`Invalid date "${value}". Expected YYYY-MM-DD.`);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
year: Number(match[1]),
|
|
22
|
+
month: Number(match[2]),
|
|
23
|
+
day: Number(match[3]),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
function plannedDateToIso(item) {
|
|
16
28
|
const year = String(item.date?.year ?? "").padStart(4, "0");
|
|
17
29
|
const month = String(item.date?.month ?? "").padStart(2, "0");
|
|
@@ -179,7 +191,7 @@ export class TrainerRoadClient {
|
|
|
179
191
|
async login({
|
|
180
192
|
username = this.username,
|
|
181
193
|
password = this.password,
|
|
182
|
-
returnPath = "/app/career
|
|
194
|
+
returnPath = username ? `/app/career/${username}` : "/app/career",
|
|
183
195
|
} = {}) {
|
|
184
196
|
if (!username || !password) {
|
|
185
197
|
throw new Error("Username and password are required for login.");
|
|
@@ -445,6 +457,288 @@ export class TrainerRoadClient {
|
|
|
445
457
|
return results;
|
|
446
458
|
}
|
|
447
459
|
|
|
460
|
+
async getWorkoutProfilesByZone(usernameForReferer = null) {
|
|
461
|
+
return this.#requestJson("/app/api/workouts/workout-profiles-by-zone", {
|
|
462
|
+
headers: {
|
|
463
|
+
"trainerroad-jsonformat": "camel-case",
|
|
464
|
+
"tr-cache-control": "use-cache",
|
|
465
|
+
referer: `${APP_URL}/workouts/list`,
|
|
466
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async searchWorkoutLibrary(predicate, usernameForReferer = null) {
|
|
472
|
+
return this.#requestJson("/app/api/workouts", {
|
|
473
|
+
method: "POST",
|
|
474
|
+
headers: {
|
|
475
|
+
"content-type": "application/json",
|
|
476
|
+
"trainerroad-jsonformat": "camel-case",
|
|
477
|
+
"tr-cache-control": "no-cache",
|
|
478
|
+
referer: `${APP_URL}/workouts/list`,
|
|
479
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
480
|
+
},
|
|
481
|
+
body: JSON.stringify(predicate),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async getWorkoutsByIds(workoutIds, usernameForReferer = null) {
|
|
486
|
+
return this.#requestJson("/app/api/workouts/by-id", {
|
|
487
|
+
method: "POST",
|
|
488
|
+
headers: {
|
|
489
|
+
"content-type": "application/json",
|
|
490
|
+
"trainerroad-jsonformat": "camel-case",
|
|
491
|
+
"tr-cache-control": "use-cache",
|
|
492
|
+
referer: `${APP_URL}/workouts/list`,
|
|
493
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
494
|
+
},
|
|
495
|
+
body: JSON.stringify(workoutIds),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async getWorkoutSummary(workoutId, usernameForReferer = null) {
|
|
500
|
+
return this.#requestJson(`/app/api/workouts/${encodeURIComponent(workoutId)}/summary`, {
|
|
501
|
+
headers: {
|
|
502
|
+
"trainerroad-jsonformat": "camel-case",
|
|
503
|
+
"tr-cache-control": "use-cache",
|
|
504
|
+
referer: `${APP_URL}/workouts/list`,
|
|
505
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async getWorkoutLevels(workoutId, usernameForReferer = null) {
|
|
511
|
+
return this.#requestJson(`/app/api/workouts/${encodeURIComponent(workoutId)}/levels`, {
|
|
512
|
+
headers: {
|
|
513
|
+
"trainerroad-jsonformat": "camel-case",
|
|
514
|
+
"tr-cache-control": "use-cache",
|
|
515
|
+
referer: `${APP_URL}/workouts/list`,
|
|
516
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async getWorkoutChartData(workoutId, usernameForReferer = null) {
|
|
522
|
+
return this.#requestJson(`/app/api/workouts/${encodeURIComponent(workoutId)}/chart-data`, {
|
|
523
|
+
headers: {
|
|
524
|
+
"trainerroad-jsonformat": "camel-case",
|
|
525
|
+
"tr-cache-control": "use-cache",
|
|
526
|
+
referer: `${APP_URL}/workouts/list`,
|
|
527
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async getWorkoutInformation(workoutIds, usernameForReferer = null) {
|
|
533
|
+
const ids = (Array.isArray(workoutIds) ? workoutIds : [])
|
|
534
|
+
.map((value) => Number(value))
|
|
535
|
+
.filter((value) => Number.isFinite(value));
|
|
536
|
+
const params = new URLSearchParams({ ids: ids.join(",") });
|
|
537
|
+
return this.#requestJson(`/app/api/workout-information?${params.toString()}`, {
|
|
538
|
+
headers: {
|
|
539
|
+
"trainerroad-jsonformat": "camel-case",
|
|
540
|
+
"tr-cache-control": "use-cache",
|
|
541
|
+
referer: `${APP_URL}/cycling/train-now`,
|
|
542
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async getTrainNowStatus(usernameForReferer = null) {
|
|
548
|
+
return this.#requestJson("/app/api/train-now", {
|
|
549
|
+
headers: {
|
|
550
|
+
"trainerroad-jsonformat": "camel-case",
|
|
551
|
+
"tr-cache-control": "use-cache",
|
|
552
|
+
referer: `${APP_URL}/cycling/train-now`,
|
|
553
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async getTrainNowSuggestions({ duration, numSuggestions = 10 } = {}, usernameForReferer = null) {
|
|
559
|
+
return this.#requestJson("/app/api/train-now", {
|
|
560
|
+
method: "POST",
|
|
561
|
+
headers: {
|
|
562
|
+
"content-type": "application/json",
|
|
563
|
+
"trainerroad-jsonformat": "camel-case",
|
|
564
|
+
"tr-cache-control": "no-cache",
|
|
565
|
+
referer: `${APP_URL}/cycling/train-now`,
|
|
566
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
567
|
+
},
|
|
568
|
+
body: JSON.stringify({ duration, numSuggestions }),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async tryAddWorkoutToCalendar(
|
|
573
|
+
workoutId,
|
|
574
|
+
dateIso,
|
|
575
|
+
{ timeOfDay = null, isManualComplete = false, outside = false, usernameForReferer } = {},
|
|
576
|
+
) {
|
|
577
|
+
const attempts = [
|
|
578
|
+
{
|
|
579
|
+
endpoint: "react-calendar",
|
|
580
|
+
path: "/app/api/react-calendar/planned-tr-workout",
|
|
581
|
+
body: {
|
|
582
|
+
date: toApiDateOnly(dateIso),
|
|
583
|
+
time: timeOfDay,
|
|
584
|
+
workoutId: Number(workoutId),
|
|
585
|
+
type: outside ? 1 : 0,
|
|
586
|
+
recommendationReason: null,
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
endpoint: "legacy-plannedactivities",
|
|
591
|
+
path: "/app/api/calendar/plannedactivities/workout",
|
|
592
|
+
body: {
|
|
593
|
+
id: Number(workoutId),
|
|
594
|
+
date: dateIso,
|
|
595
|
+
timeOfDay,
|
|
596
|
+
isManualComplete: Boolean(isManualComplete),
|
|
597
|
+
recommendationReason: null,
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
];
|
|
601
|
+
|
|
602
|
+
const results = [];
|
|
603
|
+
for (const attempt of attempts) {
|
|
604
|
+
const response = await this.#request(attempt.path, {
|
|
605
|
+
method: "POST",
|
|
606
|
+
headers: {
|
|
607
|
+
"content-type": "application/json",
|
|
608
|
+
"trainerroad-jsonformat": "camel-case",
|
|
609
|
+
"tr-cache-control": "no-cache",
|
|
610
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
611
|
+
},
|
|
612
|
+
body: JSON.stringify(attempt.body),
|
|
613
|
+
});
|
|
614
|
+
const text = await response.text();
|
|
615
|
+
let payload;
|
|
616
|
+
try {
|
|
617
|
+
payload = JSON.parse(text);
|
|
618
|
+
} catch {
|
|
619
|
+
payload = text;
|
|
620
|
+
}
|
|
621
|
+
results.push({
|
|
622
|
+
endpoint: attempt.endpoint,
|
|
623
|
+
status: response.status,
|
|
624
|
+
ok: response.ok,
|
|
625
|
+
requestBody: attempt.body,
|
|
626
|
+
response: payload,
|
|
627
|
+
});
|
|
628
|
+
if (response.ok) break;
|
|
629
|
+
}
|
|
630
|
+
return results;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async copyPlannedActivity(plannedActivityId, dateIso, usernameForReferer) {
|
|
634
|
+
const response = await this.#request(
|
|
635
|
+
`/app/api/calendar/plannedactivities/${encodeURIComponent(plannedActivityId)}/copy/${encodeURIComponent(dateIso)}`,
|
|
636
|
+
{
|
|
637
|
+
method: "POST",
|
|
638
|
+
headers: {
|
|
639
|
+
"trainerroad-jsonformat": "camel-case",
|
|
640
|
+
"tr-cache-control": "no-cache",
|
|
641
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
if (!response.ok) {
|
|
647
|
+
const text = await response.text();
|
|
648
|
+
throw new Error(
|
|
649
|
+
`Request failed: ${response.status} ${response.statusText} for copy planned activity -> ${text}`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const text = await response.text();
|
|
654
|
+
if (!text) {
|
|
655
|
+
return { ok: true, status: response.status, empty: true };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
try {
|
|
659
|
+
return JSON.parse(text);
|
|
660
|
+
} catch {
|
|
661
|
+
return { ok: true, status: response.status, raw: text };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async getPlannedActivity(plannedActivityId, usernameForReferer) {
|
|
666
|
+
return this.#requestJson(`/app/api/calendar/plannedactivities/${encodeURIComponent(plannedActivityId)}`, {
|
|
667
|
+
headers: {
|
|
668
|
+
"trainerroad-jsonformat": "camel-case",
|
|
669
|
+
"tr-cache-control": "use-cache",
|
|
670
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async getPlannedActivityAlternates(plannedActivityId, category, usernameForReferer) {
|
|
676
|
+
return this.#requestJson(
|
|
677
|
+
`/app/api/calendar/plannedactivities/${encodeURIComponent(plannedActivityId)}/alternates/${encodeURIComponent(category)}`,
|
|
678
|
+
{
|
|
679
|
+
headers: {
|
|
680
|
+
"trainerroad-jsonformat": "camel-case",
|
|
681
|
+
"tr-cache-control": "use-cache",
|
|
682
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async movePlannedActivity(plannedActivityId, newDateIso, usernameForReferer) {
|
|
689
|
+
return this.#requestJson(`/app/api/react-calendar/planned-activity/${encodeURIComponent(plannedActivityId)}/move`, {
|
|
690
|
+
method: "PUT",
|
|
691
|
+
headers: {
|
|
692
|
+
"content-type": "application/json",
|
|
693
|
+
"trainerroad-jsonformat": "camel-case",
|
|
694
|
+
"tr-cache-control": "no-cache",
|
|
695
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
696
|
+
},
|
|
697
|
+
body: JSON.stringify({ newDate: toApiDateOnly(newDateIso) }),
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async replacePlannedActivityWithAlternate(
|
|
702
|
+
plannedActivityId,
|
|
703
|
+
alternateWorkoutId,
|
|
704
|
+
{ updateDuration = false, usernameForReferer } = {},
|
|
705
|
+
) {
|
|
706
|
+
return this.#requestJson(
|
|
707
|
+
`/app/api/react-calendar/planned-activity/${encodeURIComponent(plannedActivityId)}/replace-with-alternate`,
|
|
708
|
+
{
|
|
709
|
+
method: "PUT",
|
|
710
|
+
headers: {
|
|
711
|
+
"content-type": "application/json",
|
|
712
|
+
"trainerroad-jsonformat": "camel-case",
|
|
713
|
+
"tr-cache-control": "no-cache",
|
|
714
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
715
|
+
},
|
|
716
|
+
body: JSON.stringify({
|
|
717
|
+
alternateWorkoutId: Number(alternateWorkoutId),
|
|
718
|
+
updateDuration: Boolean(updateDuration),
|
|
719
|
+
}),
|
|
720
|
+
},
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async switchPlannedActivityMode(plannedActivityId, mode, usernameForReferer) {
|
|
725
|
+
const normalizedMode = String(mode ?? "").trim().toLowerCase();
|
|
726
|
+
if (!["inside", "outside"].includes(normalizedMode)) {
|
|
727
|
+
throw new Error(`Invalid mode "${mode}". Expected "inside" or "outside".`);
|
|
728
|
+
}
|
|
729
|
+
return this.#requestJson(
|
|
730
|
+
`/app/api/react-calendar/planned-activity/${encodeURIComponent(plannedActivityId)}/switch-to-${normalizedMode}`,
|
|
731
|
+
{
|
|
732
|
+
method: "PUT",
|
|
733
|
+
headers: {
|
|
734
|
+
"trainerroad-jsonformat": "camel-case",
|
|
735
|
+
"tr-cache-control": "no-cache",
|
|
736
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
448
742
|
async getPersonalRecordsByActivityIds(memberId, usernameForReferer, activityIds) {
|
|
449
743
|
if (activityIds.length === 0) return {};
|
|
450
744
|
const batches = chunk(activityIds, 100);
|