kahunas-cli 1.0.3 → 1.0.5

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 (3) hide show
  1. package/README.md +16 -16
  2. package/dist/cli.js +778 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,22 +16,22 @@ pnpm build
16
16
  2) Log in once (opens a browser):
17
17
 
18
18
  ```bash
19
- node dist/cli.js auth login
19
+ pnpm kahunas -- auth login
20
20
  ```
21
21
 
22
22
  3) Fetch data:
23
23
 
24
24
  ```bash
25
- node dist/cli.js checkins list
26
- node dist/cli.js workout list
27
- node dist/cli.js workout pick
25
+ pnpm kahunas -- checkins list
26
+ pnpm kahunas -- workout list
27
+ pnpm kahunas -- workout pick
28
28
  ```
29
29
 
30
- You can also use the pnpm shortcut:
30
+ You can also run without installing globally:
31
31
 
32
32
  ```bash
33
- pnpm kahunas -- checkins list
34
- pnpm kahunas -- workout events
33
+ npx kahunas-cli checkins list
34
+ npx kahunas-cli workout events
35
35
  ```
36
36
 
37
37
  ## Commands
@@ -63,7 +63,7 @@ Tokens are saved to:
63
63
  - `kahunas workout latest`
64
64
  - Loads the most recently updated program.
65
65
  - `kahunas workout events`
66
- - Lists workout log events with dates (from the calendar endpoint).
66
+ - Lists workout log events with dates and a human-friendly workout summary (from the calendar endpoint).
67
67
  - `kahunas workout program <id>`
68
68
  - Fetches a program by UUID.
69
69
 
@@ -72,7 +72,7 @@ Tokens are saved to:
72
72
  If the API list is missing a program you see in the web UI, run:
73
73
 
74
74
  ```bash
75
- node dist/cli.js workout sync
75
+ pnpm kahunas -- workout sync
76
76
  ```
77
77
 
78
78
  This opens a browser, you log in, then navigate to your workouts page. After you press Enter, the CLI captures the workout list from network responses and writes a cache:
@@ -84,10 +84,10 @@ Raw output (`--raw`) prints the API response only.
84
84
 
85
85
  ### Workout events (dates)
86
86
 
87
- To see when workouts happened, the calendar endpoint returns log events with timestamps. By default each event is enriched with the full program payload (best effort; falls back to cached summary if needed).
87
+ To see when workouts happened, the calendar endpoint returns log events with timestamps. By default each event is summarized into a human-friendly structure (total volume sets, exercises, supersets). Use `--full` to return the full program payload (best effort; falls back to cached summary if needed).
88
88
 
89
89
  ```bash
90
- node dist/cli.js workout events --user <user-uuid>
90
+ pnpm kahunas -- workout events --user <user-uuid>
91
91
  ```
92
92
 
93
93
  Or via pnpm:
@@ -101,13 +101,13 @@ Default timezone is `Europe/London`. Override with `--timezone`.
101
101
  You can filter by program or workout UUID:
102
102
 
103
103
  ```bash
104
- node dist/cli.js workout events --program <program-uuid>
105
- node dist/cli.js workout events --workout <workout-uuid>
104
+ pnpm kahunas -- workout events --program <program-uuid>
105
+ pnpm kahunas -- workout events --workout <workout-uuid>
106
106
  ```
107
107
 
108
- Use `--minimal` to return the raw event objects without program enrichment.
108
+ Use `--minimal` to return the raw event objects without program enrichment. Use `--full` to return the full enriched output. Use `--latest` for only the most recent event, or `--limit N` for the most recent N events. Use `--debug-preview` to log where preview HTML was discovered (stderr only).
109
109
 
110
- The user UUID is saved automatically after `checkins list`, or you can set it:
110
+ If the user UUID is missing, `workout events` will attempt to discover it from check-ins and save it. You can also set it directly:
111
111
 
112
112
  - `KAHUNAS_USER_UUID=...`
113
113
  - `--user <uuid>`
@@ -117,7 +117,7 @@ The user UUID is saved automatically after `checkins list`, or you can set it:
117
117
  Most commands auto-login by default if a token is missing or expired. To disable:
118
118
 
119
119
  ```bash
120
- node dist/cli.js checkins list --no-auto-login
120
+ pnpm kahunas -- checkins list --no-auto-login
121
121
  ```
122
122
 
123
123
  ## Flags
package/dist/cli.js CHANGED
@@ -272,7 +272,7 @@ function extractUserUuidFromCheckins(payload) {
272
272
 
273
273
  //#endregion
274
274
  //#region src/utils.ts
275
- function parseNumber(value, fallback) {
275
+ function parseNumber$1(value, fallback) {
276
276
  if (!value) return fallback;
277
277
  const parsed = Number.parseInt(value, 10);
278
278
  if (Number.isNaN(parsed)) return fallback;
@@ -532,7 +532,7 @@ async function loginAndPersist(options, config, outputMode) {
532
532
  //#endregion
533
533
  //#region src/usage.ts
534
534
  function printUsage() {
535
- console.log(`kahunas - CLI for Kahunas API\n\nUsage:\n kahunas auth set <token> [--base-url URL] [--csrf CSRF] [--web-base-url URL] [--cookie COOKIE] [--csrf-cookie VALUE]\n kahunas auth token [--csrf CSRF] [--cookie COOKIE] [--csrf-cookie VALUE] [--web-base-url URL] [--raw]\n kahunas auth login [--web-base-url URL] [--headless] [--raw]\n kahunas auth status [--token TOKEN] [--base-url URL] [--auto-login] [--headless]\n kahunas auth show\n kahunas checkins list [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout list [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout pick [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout latest [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout events [--user UUID] [--timezone TZ] [--program UUID] [--workout UUID] [--minimal] [--raw] [--no-auto-login] [--headless]\n kahunas workout sync [--headless]\n kahunas workout program <id> [--csrf CSRF] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n\nEnv:\n KAHUNAS_TOKEN=...\n KAHUNAS_CSRF=...\n KAHUNAS_CSRF_COOKIE=...\n KAHUNAS_COOKIE=...\n KAHUNAS_WEB_BASE_URL=...\n KAHUNAS_USER_UUID=...\n\nConfig:\n ${CONFIG_PATH}`);
535
+ console.log(`kahunas - CLI for Kahunas API\n\nUsage:\n kahunas auth set <token> [--base-url URL] [--csrf CSRF] [--web-base-url URL] [--cookie COOKIE] [--csrf-cookie VALUE]\n kahunas auth token [--csrf CSRF] [--cookie COOKIE] [--csrf-cookie VALUE] [--web-base-url URL] [--raw]\n kahunas auth login [--web-base-url URL] [--headless] [--raw]\n kahunas auth status [--token TOKEN] [--base-url URL] [--auto-login] [--headless]\n kahunas auth show\n kahunas checkins list [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout list [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout pick [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout latest [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout events [--user UUID] [--timezone TZ] [--program UUID] [--workout UUID] [--minimal] [--full] [--latest] [--limit N] [--debug-preview] [--raw] [--no-auto-login] [--headless]\n kahunas workout sync [--headless]\n kahunas workout program <id> [--csrf CSRF] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n\nEnv:\n KAHUNAS_TOKEN=...\n KAHUNAS_CSRF=...\n KAHUNAS_CSRF_COOKIE=...\n KAHUNAS_COOKIE=...\n KAHUNAS_WEB_BASE_URL=...\n KAHUNAS_USER_UUID=...\n\nConfig:\n ${CONFIG_PATH}`);
536
536
  }
537
537
 
538
538
  //#endregion
@@ -663,8 +663,8 @@ async function handleCheckins(positionals, options) {
663
663
  if (!token) if (autoLogin) token = await loginAndPersist(options, config, "silent");
664
664
  else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
665
665
  const baseUrl = resolveBaseUrl(options, config);
666
- const page = parseNumber(options.page, 1);
667
- const rpp = parseNumber(options.rpp, 12);
666
+ const page = parseNumber$1(options.page, 1);
667
+ const rpp = parseNumber$1(options.rpp, 12);
668
668
  const rawOutput = isFlagEnabled(options, "raw");
669
669
  let response = await postJson("/api/v2/checkin/list", token, baseUrl, {
670
670
  page,
@@ -715,6 +715,727 @@ function enrichWorkoutEvents(events, programDetails) {
715
715
  };
716
716
  });
717
717
  }
718
+ const HTML_ENTITY_MAP = {
719
+ "&quot;": "\"",
720
+ "&#34;": "\"",
721
+ "&apos;": "'",
722
+ "&#39;": "'",
723
+ "&amp;": "&",
724
+ "&lt;": "<",
725
+ "&gt;": ">"
726
+ };
727
+ const DAY_INDEX_KEYS = [
728
+ "day_index",
729
+ "workout_day_index",
730
+ "day",
731
+ "workout_day",
732
+ "day_number",
733
+ "workout_day_number"
734
+ ];
735
+ function formatWorkoutEventsOutput(events, programDetails, options) {
736
+ return {
737
+ source: "calendar",
738
+ timezone: options.timezone,
739
+ filters: {
740
+ program: options.program ?? null,
741
+ workout: options.workout ?? null
742
+ },
743
+ events: events.map((event) => formatWorkoutEvent(event, programDetails))
744
+ };
745
+ }
746
+ function parseWorkoutDayPreview(html, dayIndex) {
747
+ const dayBlocks = extractWorkoutDayBlocks(html);
748
+ const selected = selectWorkoutDayBlock(dayBlocks, dayIndex);
749
+ if (dayBlocks.size > 1 && !selected) return null;
750
+ const dayHtml = selected?.html ?? html;
751
+ if (!dayHtml) return null;
752
+ const totalVolumeSets = parseTotalVolumeSets(dayHtml);
753
+ const sections = [];
754
+ const warmupHtml = extractSectionHtml(dayHtml, "table_warmup");
755
+ if (warmupHtml) {
756
+ const groups = buildExerciseGroups(warmupHtml);
757
+ if (groups.length > 0) sections.push({
758
+ type: "warm_up",
759
+ label: "Warm Up",
760
+ groups
761
+ });
762
+ }
763
+ const workoutHtml = extractSectionHtml(dayHtml, "table_workout");
764
+ if (workoutHtml) {
765
+ const groups = buildExerciseGroups(workoutHtml);
766
+ if (groups.length > 0) sections.push({
767
+ type: "workout",
768
+ label: "Workout",
769
+ groups
770
+ });
771
+ }
772
+ if (totalVolumeSets.length === 0 && sections.length === 0) return null;
773
+ const resolvedIndex = selected?.index ?? dayIndex;
774
+ return {
775
+ day_index: resolvedIndex,
776
+ day_label: resolvedIndex === void 0 ? void 0 : `Day ${resolvedIndex + 1}`,
777
+ total_volume_sets: totalVolumeSets,
778
+ sections
779
+ };
780
+ }
781
+ function formatWorkoutEvent(event, programDetails) {
782
+ const record = event;
783
+ const programUuid = typeof record.program === "string" ? record.program : void 0;
784
+ const program = programUuid ? programDetails[programUuid] : void 0;
785
+ const programSummary = programUuid ? extractProgramSummary(programUuid, program) : null;
786
+ const dayIndex = resolveEventDayIndex(record, program);
787
+ const previewHtml = (findWorkoutPreviewHtmlMatch(record) ?? (program ? findWorkoutPreviewHtmlMatch(program) : void 0))?.html;
788
+ let workoutDay = previewHtml ? parseWorkoutDayPreview(previewHtml, dayIndex) : null;
789
+ if (!workoutDay) workoutDay = deriveWorkoutDayFromProgram(program ?? record, record);
790
+ return {
791
+ event: {
792
+ id: typeof record.id === "number" || typeof record.id === "string" ? record.id : void 0,
793
+ start: typeof record.start === "string" ? record.start : void 0,
794
+ end: typeof record.end === "string" ? record.end : void 0,
795
+ title: typeof record.title === "string" ? record.title : void 0
796
+ },
797
+ program: programSummary,
798
+ workout_day: workoutDay
799
+ };
800
+ }
801
+ function deriveWorkoutDayFromProgram(program, event) {
802
+ if (!program || typeof program !== "object") return null;
803
+ const candidates = extractProgramDayCandidates(program);
804
+ if (candidates.length === 0) return null;
805
+ const dayIndex = resolveEventDayIndex(event, program) ?? parseDayIndexFromTitle(event.title);
806
+ if (dayIndex !== void 0) {
807
+ const match = candidates.find((candidate) => candidate.day_index === dayIndex) ?? candidates.find((candidate) => candidate.day_index === dayIndex - 1);
808
+ if (match) return match;
809
+ }
810
+ const title = typeof event.title === "string" ? event.title.trim() : "";
811
+ if (title) {
812
+ const normalizedTitle = normalizeLabel(title);
813
+ const match = candidates.find((candidate) => {
814
+ if (!candidate.day_label) return false;
815
+ const normalizedLabel = normalizeLabel(candidate.day_label);
816
+ return normalizedLabel.includes(normalizedTitle) || normalizedTitle.includes(normalizedLabel);
817
+ });
818
+ if (match) return match;
819
+ }
820
+ if (candidates.length === 1) return candidates[0];
821
+ return null;
822
+ }
823
+ function extractProgramDayCandidates(program) {
824
+ const candidates = [];
825
+ const queue = [program];
826
+ while (queue.length > 0) {
827
+ const value = queue.shift();
828
+ if (!value) continue;
829
+ if (Array.isArray(value)) {
830
+ queue.push(...value);
831
+ continue;
832
+ }
833
+ if (typeof value !== "object") continue;
834
+ const record = value;
835
+ const sections = extractSectionsFromRecord(record);
836
+ if (sections.length > 0) {
837
+ const dayIndex = resolveDayIndexFromRecord(record);
838
+ const dayLabel = resolveDayLabelFromRecord(record, dayIndex);
839
+ const totalVolume = extractTotalVolumeFromRecord(record);
840
+ candidates.push({
841
+ day_index: dayIndex,
842
+ day_label: dayLabel,
843
+ total_volume_sets: totalVolume,
844
+ sections
845
+ });
846
+ }
847
+ queue.push(...Object.values(record));
848
+ }
849
+ return candidates;
850
+ }
851
+ function extractSectionsFromRecord(record) {
852
+ const sections = [];
853
+ const exerciseListSections = extractSectionsFromExerciseList(record.exercise_list);
854
+ if (exerciseListSections.length > 0) return exerciseListSections;
855
+ const warmup = extractExercisesFromValue(record.warmup ?? record.warm_up ?? record.warm_up_exercises ?? record.warmups);
856
+ if (warmup.length > 0) sections.push({
857
+ type: "warm_up",
858
+ label: "Warm Up",
859
+ groups: wrapExercises(warmup)
860
+ });
861
+ const workout = extractExercisesFromValue(record.workout ?? record.workout_exercises ?? record.exercises ?? record.exercise);
862
+ if (workout.length > 0) sections.push({
863
+ type: "workout",
864
+ label: "Workout",
865
+ groups: wrapExercises(workout)
866
+ });
867
+ return sections;
868
+ }
869
+ function extractSectionsFromExerciseList(value) {
870
+ if (!value || typeof value !== "object") return [];
871
+ const record = value;
872
+ const sections = [];
873
+ const warmupGroups = extractExerciseGroupsFromList(record.warmup ?? record.warm_up);
874
+ if (warmupGroups.length > 0) sections.push({
875
+ type: "warm_up",
876
+ label: "Warm Up",
877
+ groups: warmupGroups
878
+ });
879
+ const workoutGroups = extractExerciseGroupsFromList(record.workout ?? record.main);
880
+ if (workoutGroups.length > 0) sections.push({
881
+ type: "workout",
882
+ label: "Workout",
883
+ groups: workoutGroups
884
+ });
885
+ const cooldownGroups = extractExerciseGroupsFromList(record.cooldown ?? record.cool_down);
886
+ if (cooldownGroups.length > 0) sections.push({
887
+ type: "workout",
888
+ label: "Cooldown",
889
+ groups: cooldownGroups
890
+ });
891
+ return sections;
892
+ }
893
+ function extractExerciseGroupsFromList(value) {
894
+ if (!Array.isArray(value)) return [];
895
+ const groups = [];
896
+ for (const entry of value) {
897
+ if (!entry || typeof entry !== "object") continue;
898
+ const record = entry;
899
+ const exercises = extractExercisesFromValue(record.list ?? record.exercises ?? record.exercise ?? record.items);
900
+ if (exercises.length === 0) continue;
901
+ const groupType = (typeof record.type === "string" ? record.type.toLowerCase() : "").includes("superset") ? "superset" : "straight";
902
+ groups.push({
903
+ type: groupType,
904
+ exercises
905
+ });
906
+ }
907
+ return groups;
908
+ }
909
+ function extractExercisesFromValue(value) {
910
+ if (!value) return [];
911
+ if (Array.isArray(value)) return extractExercisesFromArray(value);
912
+ if (typeof value === "object") {
913
+ const record = value;
914
+ if (looksLikeExerciseRecord(record)) {
915
+ const parsed = parseExerciseFromRecord(record);
916
+ return parsed ? [parsed] : [];
917
+ }
918
+ const exercises = [];
919
+ for (const entry of Object.values(record)) exercises.push(...extractExercisesFromValue(entry));
920
+ return exercises;
921
+ }
922
+ return [];
923
+ }
924
+ function extractExercisesFromArray(entries) {
925
+ const exerciseEntries = entries.filter((entry) => entry && typeof entry === "object").map((entry) => entry);
926
+ if (!exerciseEntries.some((entry) => looksLikeExerciseRecord(entry))) {
927
+ const nested = [];
928
+ for (const entry of exerciseEntries) nested.push(...extractExercisesFromValue(entry));
929
+ return nested;
930
+ }
931
+ return exerciseEntries.map((entry) => parseExerciseFromRecord(entry)).filter((entry) => Boolean(entry));
932
+ }
933
+ function looksLikeExerciseRecord(record) {
934
+ if (typeof record.exercise_name === "string") return true;
935
+ if (typeof record.exercise_uuid === "string") return true;
936
+ if (typeof record.name === "string" && (record.sets !== void 0 || record.reps !== void 0)) return true;
937
+ const nested = record.exercise;
938
+ if (nested && typeof nested === "object") {
939
+ const nestedRecord = nested;
940
+ if (typeof nestedRecord.name === "string" || typeof nestedRecord.exercise_name === "string") return true;
941
+ if (typeof nestedRecord.uuid === "string" || typeof nestedRecord.exercise_uuid === "string") return true;
942
+ }
943
+ return false;
944
+ }
945
+ function parseExerciseFromRecord(record) {
946
+ const nested = record.exercise && typeof record.exercise === "object" ? record.exercise : void 0;
947
+ const name = typeof record.exercise_name === "string" ? record.exercise_name : typeof record.name === "string" ? record.name : typeof nested?.name === "string" ? nested.name : typeof nested?.exercise_name === "string" ? nested.exercise_name : typeof nested?.title === "string" ? nested.title : void 0;
948
+ if (!name) return null;
949
+ const sets = parseNumber(record.sets);
950
+ const reps = typeof record.reps === "string" ? record.reps : typeof record.reps === "number" ? String(record.reps) : typeof record.rep === "string" ? record.rep : typeof record.repetition === "string" ? record.repetition : void 0;
951
+ const rest = parseNumber(record.rest_period ?? record.rest ?? record.rest_seconds);
952
+ const time = parseNumber(record.time_period ?? record.time ?? record.duration);
953
+ const bodyParts = extractBodyPartsFromValue(record.bodypart ?? record.body_parts ?? nested?.bodypart ?? nested?.body_parts);
954
+ const sequence = typeof record.number === "string" || typeof record.number === "number" ? String(record.number) : typeof record.sequence === "string" || typeof record.sequence === "number" ? String(record.sequence) : typeof record.order === "string" || typeof record.order === "number" ? String(record.order) : typeof record.exercise_order === "string" || typeof record.exercise_order === "number" ? String(record.exercise_order) : typeof record.group_order === "string" || typeof record.group_order === "number" ? String(record.group_order) : void 0;
955
+ return {
956
+ name,
957
+ uuid: typeof record.exercise_uuid === "string" ? record.exercise_uuid : typeof nested?.uuid === "string" ? nested.uuid : typeof nested?.exercise_uuid === "string" ? nested.exercise_uuid : void 0,
958
+ sets: sets ?? parseNumber(nested?.sets),
959
+ reps,
960
+ rest_seconds: rest ?? parseNumber(nested?.rest_period),
961
+ time_seconds: time ?? parseNumber(nested?.time_period),
962
+ notes: typeof record.notes === "string" ? record.notes : void 0,
963
+ sequence,
964
+ body_parts: bodyParts.length > 0 ? bodyParts : void 0
965
+ };
966
+ }
967
+ function extractBodyPartsFromValue(value) {
968
+ if (!value) return [];
969
+ if (!Array.isArray(value)) return [];
970
+ return value.filter((entry) => entry && typeof entry === "object").map((entry) => entry).map((entry) => {
971
+ const name = typeof entry.body_part_name === "string" ? entry.body_part_name : void 0;
972
+ if (!name) return;
973
+ const rawVolume = typeof entry.body_volume === "number" ? entry.body_volume : typeof entry.body_volume === "string" ? Number.parseFloat(entry.body_volume) : void 0;
974
+ const volume = Number.isFinite(rawVolume ?? NaN) ? rawVolume : void 0;
975
+ const result = { name };
976
+ if (volume !== void 0) result.volume = volume;
977
+ return result;
978
+ }).filter((entry) => Boolean(entry && entry.name));
979
+ }
980
+ function extractTotalVolumeFromRecord(record) {
981
+ const bodypart = record.bodypart ?? record.body_parts;
982
+ if (!Array.isArray(bodypart)) return [];
983
+ const totals = [];
984
+ for (const entry of bodypart) {
985
+ if (!entry || typeof entry !== "object") continue;
986
+ const data = entry;
987
+ if (typeof data.body_part_name !== "string") continue;
988
+ const setsValue = typeof data.body_volume === "number" ? data.body_volume : typeof data.body_volume === "string" ? Number.parseFloat(data.body_volume) : void 0;
989
+ if (setsValue === void 0 || !Number.isFinite(setsValue)) continue;
990
+ totals.push({
991
+ body_part: data.body_part_name,
992
+ sets: setsValue
993
+ });
994
+ }
995
+ return totals;
996
+ }
997
+ function wrapExercises(exercises) {
998
+ return [...exercises].sort((a, b) => {
999
+ const aSeq = parseSequence(a.sequence);
1000
+ const bSeq = parseSequence(b.sequence);
1001
+ if (aSeq !== void 0 && bSeq !== void 0) return aSeq - bSeq;
1002
+ if (aSeq !== void 0) return -1;
1003
+ if (bSeq !== void 0) return 1;
1004
+ return 0;
1005
+ }).map((exercise) => ({
1006
+ type: "straight",
1007
+ exercises: [exercise]
1008
+ }));
1009
+ }
1010
+ function parseSequence(value) {
1011
+ if (!value) return;
1012
+ const parsed = Number.parseFloat(value);
1013
+ return Number.isFinite(parsed) ? parsed : void 0;
1014
+ }
1015
+ function resolveDayIndexFromRecord(record) {
1016
+ for (const key of DAY_INDEX_KEYS) {
1017
+ const parsed = parseIndex(record[key]);
1018
+ if (parsed !== void 0) return parsed;
1019
+ }
1020
+ }
1021
+ function resolveDayLabelFromRecord(record, dayIndex) {
1022
+ const label = typeof record.day_title === "string" ? record.day_title : typeof record.day_name === "string" ? record.day_name : typeof record.title === "string" ? record.title : typeof record.name === "string" ? record.name : void 0;
1023
+ if (label) return label.trim();
1024
+ if (dayIndex !== void 0) return `Day ${dayIndex + 1}`;
1025
+ }
1026
+ function parseDayIndexFromTitle(value) {
1027
+ if (typeof value !== "string") return;
1028
+ const match = value.match(/\\bday\\s*(\\d+)/i);
1029
+ if (!match) return;
1030
+ const parsed = Number.parseInt(match[1], 10);
1031
+ if (!Number.isFinite(parsed)) return;
1032
+ return parsed - 1;
1033
+ }
1034
+ function normalizeLabel(value) {
1035
+ return value.trim().toLowerCase();
1036
+ }
1037
+ function resolveWorkoutEventDayIndex(event, program) {
1038
+ return resolveEventDayIndex(event, program);
1039
+ }
1040
+ function findWorkoutPreviewHtmlMatch(value) {
1041
+ const queue = [{
1042
+ value,
1043
+ path: "$"
1044
+ }];
1045
+ while (queue.length > 0) {
1046
+ const current = queue.shift();
1047
+ if (!current) continue;
1048
+ const { value: entry, path } = current;
1049
+ if (typeof entry === "string") {
1050
+ if (entry.includes("workoutdays_data") || entry.includes("preview_day_content") || entry.includes("table_workout")) return {
1051
+ html: entry,
1052
+ source: path
1053
+ };
1054
+ continue;
1055
+ }
1056
+ if (Array.isArray(entry)) {
1057
+ entry.forEach((item, index) => {
1058
+ queue.push({
1059
+ value: item,
1060
+ path: `${path}[${index}]`
1061
+ });
1062
+ });
1063
+ continue;
1064
+ }
1065
+ if (entry && typeof entry === "object") for (const [key, child] of Object.entries(entry)) queue.push({
1066
+ value: child,
1067
+ path: `${path}.${formatPathKey(key)}`
1068
+ });
1069
+ }
1070
+ }
1071
+ function resolveEventDayIndex(event, program) {
1072
+ for (const key of DAY_INDEX_KEYS) {
1073
+ const value = event[key];
1074
+ const parsed = parseIndex(value);
1075
+ if (parsed !== void 0) return parsed;
1076
+ }
1077
+ const workoutUuid = typeof event.workout === "string" ? event.workout : typeof event.workout_uuid === "string" ? event.workout_uuid : void 0;
1078
+ if (workoutUuid) {
1079
+ const found = findDayIndexByWorkoutUuid(program, workoutUuid);
1080
+ if (found !== void 0) return found;
1081
+ }
1082
+ const workoutDayUuid = typeof event.workout_day_uuid === "string" ? event.workout_day_uuid : void 0;
1083
+ if (workoutDayUuid) {
1084
+ const found = findDayIndexByUuid(program, workoutDayUuid);
1085
+ if (found !== void 0) return found;
1086
+ }
1087
+ const workoutDayId = parseIndex(event.workout_day_id ?? event.day_id);
1088
+ if (workoutDayId !== void 0) {
1089
+ const found = findDayIndexById(program, workoutDayId);
1090
+ if (found !== void 0) return found;
1091
+ }
1092
+ }
1093
+ function extractProgramSummary(uuid, program) {
1094
+ if (!program || typeof program !== "object") return { uuid };
1095
+ const record = program;
1096
+ return {
1097
+ uuid,
1098
+ title: typeof record.title === "string" ? record.title : typeof record.name === "string" ? record.name : void 0
1099
+ };
1100
+ }
1101
+ function extractWorkoutDayBlocks(html) {
1102
+ const blocks = /* @__PURE__ */ new Map();
1103
+ const regex = /<div[^>]*\bid=(["'])day_content_(\d+)\1[^>]*>/gi;
1104
+ let match;
1105
+ while ((match = regex.exec(html)) !== null) {
1106
+ const index = Number.parseInt(match[2], 10);
1107
+ if (Number.isNaN(index)) continue;
1108
+ const start = match.index;
1109
+ const block = extractDivBlock(html, start);
1110
+ if (block) blocks.set(index, block);
1111
+ }
1112
+ return blocks;
1113
+ }
1114
+ function selectWorkoutDayBlock(blocks, dayIndex) {
1115
+ if (dayIndex !== void 0) {
1116
+ const direct = blocks.get(dayIndex);
1117
+ if (direct) return {
1118
+ index: dayIndex,
1119
+ html: direct
1120
+ };
1121
+ const adjusted = blocks.get(dayIndex - 1);
1122
+ if (adjusted && dayIndex > 0) return {
1123
+ index: dayIndex - 1,
1124
+ html: adjusted
1125
+ };
1126
+ }
1127
+ if (blocks.size === 1) {
1128
+ const [index, html] = Array.from(blocks.entries())[0];
1129
+ return {
1130
+ index,
1131
+ html
1132
+ };
1133
+ }
1134
+ for (const [index, html] of blocks.entries()) if (/display\s*:\s*block/i.test(html)) return {
1135
+ index,
1136
+ html
1137
+ };
1138
+ if (blocks.size > 0) {
1139
+ const [index, html] = Array.from(blocks.entries())[0];
1140
+ return {
1141
+ index,
1142
+ html
1143
+ };
1144
+ }
1145
+ }
1146
+ function extractDivBlock(html, startIndex) {
1147
+ const tagRegex = /<div\b[^>]*>|<\/div\s*>/gi;
1148
+ tagRegex.lastIndex = startIndex;
1149
+ let depth = 0;
1150
+ let match;
1151
+ while ((match = tagRegex.exec(html)) !== null) if (match[0].startsWith("</")) {
1152
+ depth -= 1;
1153
+ if (depth === 0) return html.slice(startIndex, tagRegex.lastIndex);
1154
+ } else depth += 1;
1155
+ return html.slice(startIndex);
1156
+ }
1157
+ function extractSectionHtml(html, className) {
1158
+ const marker = html.indexOf(className);
1159
+ if (marker === -1) return;
1160
+ const divStart = html.lastIndexOf("<div", marker);
1161
+ if (divStart === -1) return;
1162
+ return extractDivBlock(html, divStart);
1163
+ }
1164
+ function parseTotalVolumeSets(html) {
1165
+ const results = [];
1166
+ const regex = /<span[^>]*total-volume-span[^>]*>(.*?)<\/span>/gi;
1167
+ let match;
1168
+ while ((match = regex.exec(html)) !== null) {
1169
+ const parsed = parseBodyPartVolume(decodeHtmlEntities(match[1].trim()));
1170
+ if (parsed) results.push(parsed);
1171
+ }
1172
+ return results;
1173
+ }
1174
+ function parseBodyPartVolume(value) {
1175
+ const match = value.match(/^(.*?)(?:\s+)(-?\d+(?:\.\d+)?)$/);
1176
+ if (!match) return;
1177
+ const sets = Number.parseFloat(match[2]);
1178
+ if (!Number.isFinite(sets)) return;
1179
+ return {
1180
+ body_part: match[1].trim(),
1181
+ sets
1182
+ };
1183
+ }
1184
+ function buildExerciseGroups(sectionHtml) {
1185
+ const supersetTables = extractSupersetTables(sectionHtml);
1186
+ const supersetRanges = supersetTables.map((table) => ({
1187
+ start: table.start,
1188
+ end: table.end
1189
+ }));
1190
+ const supersetGroups = [];
1191
+ for (const table of supersetTables) {
1192
+ const exercises = parseExercisesWithIndex(table.html).map((row) => row.exercise);
1193
+ if (exercises.length === 0) continue;
1194
+ supersetGroups.push({
1195
+ index: table.start,
1196
+ group: {
1197
+ type: "superset",
1198
+ label: "Superset",
1199
+ exercises
1200
+ }
1201
+ });
1202
+ }
1203
+ const straightGroups = [];
1204
+ for (const row of parseExercisesWithIndex(sectionHtml)) {
1205
+ if (isIndexInRanges(row.index, supersetRanges)) continue;
1206
+ if (row.classValue.includes("subrow")) continue;
1207
+ straightGroups.push({
1208
+ index: row.index,
1209
+ group: {
1210
+ type: "straight",
1211
+ exercises: [row.exercise]
1212
+ }
1213
+ });
1214
+ }
1215
+ return [...supersetGroups, ...straightGroups].sort((a, b) => a.index - b.index).map((entry) => entry.group);
1216
+ }
1217
+ function parseExercisesWithIndex(html) {
1218
+ const rows = [];
1219
+ const regex = /<tr\b[^>]*\bdata-exercise_name=(["']).*?\1[^>]*>/gi;
1220
+ let match;
1221
+ while ((match = regex.exec(html)) !== null) {
1222
+ const tag = match[0];
1223
+ const exercise = parseExerciseFromTag(tag);
1224
+ if (!exercise) continue;
1225
+ rows.push({
1226
+ index: match.index,
1227
+ exercise,
1228
+ classValue: extractAttribute(tag, "class") ?? ""
1229
+ });
1230
+ }
1231
+ return rows;
1232
+ }
1233
+ function parseExerciseFromTag(tag) {
1234
+ const attrs = extractDataAttributes(tag);
1235
+ const name = attrs["exercise_name"];
1236
+ if (!name) return null;
1237
+ const sets = parseNumber(attrs["sets"]);
1238
+ const restSeconds = parseNumber(attrs["rest_period"]);
1239
+ const timeSeconds = parseNumber(attrs["time_period"]);
1240
+ const bodyParts = parseBodyParts(attrs["bodypart"]);
1241
+ const media = parseMedia(attrs["media"]);
1242
+ return {
1243
+ name,
1244
+ uuid: attrs["exercise_uuid"],
1245
+ sets,
1246
+ reps: attrs["reps"] || void 0,
1247
+ rest_seconds: restSeconds,
1248
+ time_seconds: timeSeconds,
1249
+ notes: attrs["notes"] || void 0,
1250
+ sequence: attrs["number"] || void 0,
1251
+ body_parts: bodyParts.length > 0 ? bodyParts : void 0,
1252
+ media: media.length > 0 ? media : void 0
1253
+ };
1254
+ }
1255
+ function extractSupersetTables(html) {
1256
+ const candidates = extractTableBlocks(html).filter((table) => /Superset/i.test(table.html));
1257
+ return candidates.filter((candidate) => !containsSmallerSuperset(candidate, candidates));
1258
+ }
1259
+ function containsSmallerSuperset(candidate, candidates) {
1260
+ const candidateSize = candidate.end - candidate.start;
1261
+ return candidates.some((other) => {
1262
+ if (other === candidate) return false;
1263
+ const otherSize = other.end - other.start;
1264
+ return other.start >= candidate.start && other.end <= candidate.end && otherSize < candidateSize;
1265
+ });
1266
+ }
1267
+ function extractTableBlocks(html) {
1268
+ const blocks = [];
1269
+ const regex = /<table\b[^>]*>/gi;
1270
+ let match;
1271
+ while ((match = regex.exec(html)) !== null) {
1272
+ const start = match.index;
1273
+ const block = extractTableBlock(html, start);
1274
+ if (!block) continue;
1275
+ blocks.push({
1276
+ start,
1277
+ end: block.end,
1278
+ html: block.html
1279
+ });
1280
+ }
1281
+ return blocks;
1282
+ }
1283
+ function extractTableBlock(html, startIndex) {
1284
+ const tagRegex = /<table\b[^>]*>|<\/table\s*>/gi;
1285
+ tagRegex.lastIndex = startIndex;
1286
+ let depth = 0;
1287
+ let match;
1288
+ while ((match = tagRegex.exec(html)) !== null) if (match[0].startsWith("</")) {
1289
+ depth -= 1;
1290
+ if (depth === 0) return {
1291
+ html: html.slice(startIndex, tagRegex.lastIndex),
1292
+ end: tagRegex.lastIndex
1293
+ };
1294
+ } else depth += 1;
1295
+ return {
1296
+ html: html.slice(startIndex),
1297
+ end: html.length
1298
+ };
1299
+ }
1300
+ function parseBodyParts(value) {
1301
+ if (!value) return [];
1302
+ const parsed = parseJson(decodeHtmlEntities(value));
1303
+ if (!Array.isArray(parsed)) return [];
1304
+ const results = [];
1305
+ for (const entry of parsed) {
1306
+ if (!entry || typeof entry !== "object") continue;
1307
+ const record = entry;
1308
+ const name = typeof record.body_part_name === "string" ? record.body_part_name : void 0;
1309
+ if (!name) continue;
1310
+ const volumeValue = typeof record.body_volume === "number" ? record.body_volume : typeof record.body_volume === "string" ? Number.parseFloat(record.body_volume) : void 0;
1311
+ results.push({
1312
+ name,
1313
+ volume: Number.isFinite(volumeValue) ? volumeValue : void 0
1314
+ });
1315
+ }
1316
+ return results;
1317
+ }
1318
+ function parseMedia(value) {
1319
+ if (!value) return [];
1320
+ const parsed = parseJson(decodeHtmlEntities(value));
1321
+ if (!Array.isArray(parsed)) return [];
1322
+ const results = [];
1323
+ for (const entry of parsed) {
1324
+ if (!entry || typeof entry !== "object") continue;
1325
+ const record = entry;
1326
+ const fileUrl = typeof record.file_url === "string" ? record.file_url : void 0;
1327
+ const fileType = typeof record.file_type === "number" ? record.file_type : void 0;
1328
+ const mediaEntry = { file_type: fileType };
1329
+ if (fileType === 2) mediaEntry.thumbnail_url = fileUrl;
1330
+ else mediaEntry.file_url = fileUrl;
1331
+ if (mediaEntry.file_url || mediaEntry.thumbnail_url) results.push(mediaEntry);
1332
+ }
1333
+ return results;
1334
+ }
1335
+ function extractDataAttributes(tag) {
1336
+ const attrs = {};
1337
+ const regex = /\sdata-([a-z0-9_-]+)=(["'])(.*?)\2/gi;
1338
+ let match;
1339
+ while ((match = regex.exec(tag)) !== null) attrs[match[1]] = decodeHtmlEntities(match[3]);
1340
+ return attrs;
1341
+ }
1342
+ function extractAttribute(tag, name) {
1343
+ const regex = new RegExp(`\\s${name}=(["'])(.*?)\\1`, "i");
1344
+ const match = tag.match(regex);
1345
+ return match ? match[2] : void 0;
1346
+ }
1347
+ function decodeHtmlEntities(value) {
1348
+ let result = value;
1349
+ for (const [entity, replacement] of Object.entries(HTML_ENTITY_MAP)) result = result.split(entity).join(replacement);
1350
+ return result;
1351
+ }
1352
+ function isIndexInRanges(index, ranges) {
1353
+ return ranges.some((range) => index >= range.start && index < range.end);
1354
+ }
1355
+ function parseNumber(value) {
1356
+ if (!value) return;
1357
+ const parsed = Number.parseFloat(value);
1358
+ return Number.isFinite(parsed) ? parsed : void 0;
1359
+ }
1360
+ function parseIndex(value) {
1361
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1362
+ if (typeof value === "string" && value.trim() !== "") {
1363
+ const parsed = Number.parseInt(value, 10);
1364
+ return Number.isFinite(parsed) ? parsed : void 0;
1365
+ }
1366
+ }
1367
+ function parseJson(value) {
1368
+ try {
1369
+ return JSON.parse(value);
1370
+ } catch {
1371
+ return;
1372
+ }
1373
+ }
1374
+ function findDayIndexByWorkoutUuid(program, workoutUuid) {
1375
+ const queue = [{ value: program }];
1376
+ while (queue.length > 0) {
1377
+ const { value, indexInArray } = queue.shift() ?? {};
1378
+ if (value === void 0 || value === null) continue;
1379
+ if (Array.isArray(value)) {
1380
+ value.forEach((entry, index) => {
1381
+ queue.push({
1382
+ value: entry,
1383
+ indexInArray: index
1384
+ });
1385
+ });
1386
+ continue;
1387
+ }
1388
+ if (typeof value === "object") {
1389
+ const record = value;
1390
+ const candidateUuid = typeof record.workout_uuid === "string" ? record.workout_uuid : typeof record.workout === "string" ? record.workout : typeof record.uuid === "string" ? record.uuid : void 0;
1391
+ if (candidateUuid && candidateUuid === workoutUuid) {
1392
+ for (const key of DAY_INDEX_KEYS) {
1393
+ const parsed = parseIndex(record[key]);
1394
+ if (parsed !== void 0) return parsed;
1395
+ }
1396
+ if (indexInArray !== void 0) return indexInArray;
1397
+ }
1398
+ for (const entry of Object.values(record)) queue.push({ value: entry });
1399
+ }
1400
+ }
1401
+ }
1402
+ function findDayIndexByUuid(program, uuid) {
1403
+ return findDayIndexByMatcher(program, (record) => record.uuid === uuid);
1404
+ }
1405
+ function findDayIndexById(program, id) {
1406
+ return findDayIndexByMatcher(program, (record) => record.id === id);
1407
+ }
1408
+ function findDayIndexByMatcher(program, matcher) {
1409
+ const queue = [{ value: program }];
1410
+ while (queue.length > 0) {
1411
+ const { value, indexInArray } = queue.shift() ?? {};
1412
+ if (value === void 0 || value === null) continue;
1413
+ if (Array.isArray(value)) {
1414
+ value.forEach((entry, index) => {
1415
+ queue.push({
1416
+ value: entry,
1417
+ indexInArray: index
1418
+ });
1419
+ });
1420
+ continue;
1421
+ }
1422
+ if (typeof value === "object") {
1423
+ const record = value;
1424
+ if (matcher(record)) {
1425
+ for (const key of DAY_INDEX_KEYS) {
1426
+ const parsed = parseIndex(record[key]);
1427
+ if (parsed !== void 0) return parsed;
1428
+ }
1429
+ if (indexInArray !== void 0) return indexInArray;
1430
+ }
1431
+ for (const entry of Object.values(record)) queue.push({ value: entry });
1432
+ }
1433
+ }
1434
+ }
1435
+ function formatPathKey(key) {
1436
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) return key;
1437
+ return `\"${key.replace(/\"/g, "\\\"")}\"`;
1438
+ }
718
1439
 
719
1440
  //#endregion
720
1441
  //#region src/commands/workout.ts
@@ -734,8 +1455,8 @@ async function handleWorkout(positionals, options) {
734
1455
  };
735
1456
  const baseUrl = resolveBaseUrl(options, config);
736
1457
  const rawOutput = isFlagEnabled(options, "raw");
737
- const page = parseNumber(options.page, 1);
738
- const rpp = parseNumber(options.rpp, 12);
1458
+ const page = parseNumber$1(options.page, 1);
1459
+ const rpp = parseNumber$1(options.rpp, 12);
739
1460
  const listRpp = action === "latest" && options.rpp === void 0 ? 100 : rpp;
740
1461
  const fetchList = async () => {
741
1462
  await ensureToken();
@@ -821,12 +1542,39 @@ async function handleWorkout(positionals, options) {
821
1542
  const webOrigin = new URL(baseWebUrl).origin;
822
1543
  const timezone = options.timezone ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "Europe/London";
823
1544
  let userUuid = resolveUserUuid(options, config);
824
- if (!userUuid) throw new Error("Missing user uuid. Use --user or set KAHUNAS_USER_UUID.");
1545
+ if (!userUuid) try {
1546
+ await ensureToken();
1547
+ let checkinsResponse = await postJson("/api/v2/checkin/list", token, baseUrl, {
1548
+ page: 1,
1549
+ rpp: 1
1550
+ });
1551
+ if (autoLogin && isTokenExpiredResponse(checkinsResponse.json)) {
1552
+ token = await loginAndPersist(options, config, "silent");
1553
+ checkinsResponse = await postJson("/api/v2/checkin/list", token, baseUrl, {
1554
+ page: 1,
1555
+ rpp: 1
1556
+ });
1557
+ }
1558
+ if (checkinsResponse.ok) {
1559
+ const extracted = extractUserUuidFromCheckins(checkinsResponse.json);
1560
+ if (extracted) {
1561
+ userUuid = extracted;
1562
+ if (userUuid !== config.userUuid) writeConfig({
1563
+ ...config,
1564
+ userUuid
1565
+ });
1566
+ }
1567
+ }
1568
+ } catch {}
1569
+ if (!userUuid) throw new Error("Missing user uuid. Use --user or run 'kahunas checkins list' once.");
825
1570
  if (userUuid !== config.userUuid) writeConfig({
826
1571
  ...config,
827
1572
  userUuid
828
1573
  });
829
1574
  const minimal = isFlagEnabled(options, "minimal");
1575
+ const full = isFlagEnabled(options, "full");
1576
+ const debugPreview = isFlagEnabled(options, "debug-preview");
1577
+ const limit = isFlagEnabled(options, "latest") || isFlagEnabled(options, "last") ? 1 : parseNumber$1(options.limit, 0);
830
1578
  let csrfToken$1 = resolveCsrfToken(options, config);
831
1579
  let csrfCookie = resolveCsrfCookie(options, config);
832
1580
  let authCookie = resolveAuthCookie(options, config);
@@ -896,8 +1644,9 @@ async function handleWorkout(positionals, options) {
896
1644
  return;
897
1645
  }
898
1646
  const sorted = sortWorkoutEvents(filterWorkoutEvents(payload, options.program, options.workout));
1647
+ const limited = limit > 0 ? sorted.slice(-limit) : sorted;
899
1648
  if (minimal) {
900
- console.log(JSON.stringify(sorted, null, 2));
1649
+ console.log(JSON.stringify(limited, null, 2));
901
1650
  return;
902
1651
  }
903
1652
  let programIndex;
@@ -945,8 +1694,27 @@ async function handleWorkout(positionals, options) {
945
1694
  } catch {}
946
1695
  programDetails[programId$1] = programIndex?.[programId$1] ?? null;
947
1696
  }
948
- const enriched = enrichWorkoutEvents(sorted, programDetails);
949
- console.log(JSON.stringify(enriched, null, 2));
1697
+ if (debugPreview) for (const entry of limited) {
1698
+ const record = entry;
1699
+ const eventId = typeof record.id === "string" || typeof record.id === "number" ? record.id : "unknown";
1700
+ const programUuid = typeof record.program === "string" ? record.program : void 0;
1701
+ const program = programUuid ? programDetails[programUuid] : void 0;
1702
+ const match = findWorkoutPreviewHtmlMatch(record) ?? (program ? findWorkoutPreviewHtmlMatch(program) : void 0);
1703
+ const dayIndex = resolveWorkoutEventDayIndex(entry, program);
1704
+ const source = match ? match.source : "not_found";
1705
+ console.error(`debug-preview event=${eventId} program=${programUuid ?? "unknown"} day_index=${dayIndex ?? "none"} source=${source}`);
1706
+ }
1707
+ if (full) {
1708
+ const enriched = enrichWorkoutEvents(limited, programDetails);
1709
+ console.log(JSON.stringify(enriched, null, 2));
1710
+ return;
1711
+ }
1712
+ const formatted = formatWorkoutEventsOutput(limited, programDetails, {
1713
+ timezone,
1714
+ program: options.program,
1715
+ workout: options.workout
1716
+ });
1717
+ console.log(JSON.stringify(formatted, null, 2));
950
1718
  return;
951
1719
  }
952
1720
  if (action === "sync") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kahunas-cli",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {