@workingdevshero/olympia-ait-adapter 0.1.5 → 1.1.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.
Files changed (70) hide show
  1. package/README.md +139 -35
  2. package/dist/index.d.ts +6 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +6 -3
  5. package/dist/index.js.map +1 -1
  6. package/dist/reader/cell-utils.d.ts +10 -0
  7. package/dist/reader/cell-utils.d.ts.map +1 -0
  8. package/dist/reader/cell-utils.js +40 -0
  9. package/dist/reader/cell-utils.js.map +1 -0
  10. package/dist/reader/parse-day-sheet.d.ts +0 -1
  11. package/dist/reader/parse-day-sheet.d.ts.map +1 -1
  12. package/dist/reader/parse-day-sheet.js +95 -75
  13. package/dist/reader/parse-day-sheet.js.map +1 -1
  14. package/dist/reader/parse-warmup-workbook.d.ts +13 -0
  15. package/dist/reader/parse-warmup-workbook.d.ts.map +1 -0
  16. package/dist/reader/parse-warmup-workbook.js +154 -0
  17. package/dist/reader/parse-warmup-workbook.js.map +1 -0
  18. package/dist/reader/parse-workbook.d.ts +2 -2
  19. package/dist/reader/parse-workbook.d.ts.map +1 -1
  20. package/dist/reader/parse-workbook.js +1 -6
  21. package/dist/reader/parse-workbook.js.map +1 -1
  22. package/dist/schema/program.d.ts +398 -67
  23. package/dist/schema/program.d.ts.map +1 -1
  24. package/dist/schema/program.js +135 -28
  25. package/dist/schema/program.js.map +1 -1
  26. package/dist/schema/warmup.d.ts +426 -0
  27. package/dist/schema/warmup.d.ts.map +1 -0
  28. package/dist/schema/warmup.js +100 -0
  29. package/dist/schema/warmup.js.map +1 -0
  30. package/dist/server.d.ts.map +1 -1
  31. package/dist/server.js +51 -18
  32. package/dist/server.js.map +1 -1
  33. package/dist/tools/program-json-to-xlsx.d.ts +3 -0
  34. package/dist/tools/program-json-to-xlsx.d.ts.map +1 -0
  35. package/dist/tools/program-json-to-xlsx.js +5 -0
  36. package/dist/tools/program-json-to-xlsx.js.map +1 -0
  37. package/dist/tools/program-xlsx-to-json.d.ts +3 -0
  38. package/dist/tools/program-xlsx-to-json.d.ts.map +1 -0
  39. package/dist/tools/program-xlsx-to-json.js +5 -0
  40. package/dist/tools/program-xlsx-to-json.js.map +1 -0
  41. package/dist/tools/warmup-json-to-xlsx.d.ts +3 -0
  42. package/dist/tools/warmup-json-to-xlsx.d.ts.map +1 -0
  43. package/dist/tools/warmup-json-to-xlsx.js +5 -0
  44. package/dist/tools/warmup-json-to-xlsx.js.map +1 -0
  45. package/dist/tools/warmup-xlsx-to-json.d.ts +3 -0
  46. package/dist/tools/warmup-xlsx-to-json.d.ts.map +1 -0
  47. package/dist/tools/warmup-xlsx-to-json.js +5 -0
  48. package/dist/tools/warmup-xlsx-to-json.js.map +1 -0
  49. package/dist/writer/build-warmup-workbook.d.ts +3 -0
  50. package/dist/writer/build-warmup-workbook.d.ts.map +1 -0
  51. package/dist/writer/build-warmup-workbook.js +101 -0
  52. package/dist/writer/build-warmup-workbook.js.map +1 -0
  53. package/dist/writer/build-workbook.d.ts +2 -2
  54. package/dist/writer/build-workbook.d.ts.map +1 -1
  55. package/dist/writer/build-workbook.js +19 -23
  56. package/dist/writer/build-workbook.js.map +1 -1
  57. package/dist/writer/layout.d.ts +19 -7
  58. package/dist/writer/layout.d.ts.map +1 -1
  59. package/dist/writer/layout.js +40 -21
  60. package/dist/writer/layout.js.map +1 -1
  61. package/dist/writer/stamp-exercise.d.ts +13 -5
  62. package/dist/writer/stamp-exercise.d.ts.map +1 -1
  63. package/dist/writer/stamp-exercise.js +25 -18
  64. package/dist/writer/stamp-exercise.js.map +1 -1
  65. package/dist/writer/supplemental-template.d.ts +3 -2
  66. package/dist/writer/supplemental-template.d.ts.map +1 -1
  67. package/dist/writer/supplemental-template.js +15 -15
  68. package/dist/writer/supplemental-template.js.map +1 -1
  69. package/package.json +5 -4
  70. package/skills/olympia-authoring/SKILL.md +140 -0
package/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  # olympia-ait-adapter
6
6
 
7
- MCP adapter that converts Olympia Fitness & Performance program XLSX workbooks
8
- to and from a strict JSON schema. Built for the
9
- [Automate It](https://automate.it.com) framework so AI agents can author new
10
- strength-and-conditioning programs from prior client data without ever
11
- touching XLSX directly.
7
+ MCP adapter that converts Olympia Fitness & Performance XLSX workbooks —
8
+ training programs and their companion warm-up documents — to and from strict
9
+ JSON schemas. Built for the [Automate It](https://automate.it.com) framework
10
+ so AI agents can author new strength-and-conditioning paperwork from prior
11
+ client data without ever touching XLSX directly.
12
12
 
13
13
  ## Why this exists
14
14
 
@@ -17,37 +17,48 @@ file, one sheet per training day, with merged cells, custom borders, an
17
17
  embedded logo, and a domain-specific notation (`12E`, `:20E`, `12EL`,
18
18
  `x3 Rest :60`, etc.). Asking an LLM to edit XLSX directly is fragile: it
19
19
  corrupts merges, loses the logo, and can't reason about the implicit
20
- 6-week / 1A-1B / set-by-set structure encoded in the visual layout.
20
+ multi-week / 1A-1B / set-by-set structure encoded in the visual layout.
21
21
 
22
- This package gives the agent a clean structured view of an Olympia program
22
+ Each program also ships with a **companion warm-up workbook** — same client,
23
+ same date, paired day-for-day (the "Day 4" warm-up column is performed
24
+ before the "Day 4" program sheet). It's a completely different layout: one
25
+ sheet where each day is a column of titled sections of freeform exercise
26
+ lines.
27
+
28
+ This package gives the agent a clean structured view of both document types
23
29
  and guarantees well-formed output: the LLM reads JSON, reasons about it,
24
30
  writes JSON, and the adapter handles all the visual fidelity on the way
25
31
  back to XLSX.
26
32
 
27
33
  ## What it does
28
34
 
29
- Two MCP tools, exposed over stdio:
35
+ Four MCP tools, exposed over stdio:
30
36
 
31
- | Tool | Direction | Purpose |
32
- | ------------------------ | --------------- | ------------------------------------------------------------------ |
33
- | `olympia_xlsx_to_json` | XLSX → JSON | Parse an Olympia workbook into a typed `Program` document. |
34
- | `olympia_json_to_xlsx` | JSON → XLSX | Render a `Program` document back to a styled .xlsx on disk. |
37
+ | Tool | Direction | Purpose |
38
+ | ------------------------------ | ----------- | ---------------------------------------------------------------- |
39
+ | `olympia_program_xlsx_to_json` | XLSX → JSON | Parse an Olympia program workbook into a typed `Program`. |
40
+ | `olympia_program_json_to_xlsx` | JSON → XLSX | Render a `Program` back to a styled .xlsx on disk. |
41
+ | `olympia_warmup_xlsx_to_json` | XLSX → JSON | Parse a companion warm-up workbook into a typed `WarmUpDocument`. |
42
+ | `olympia_warmup_json_to_xlsx` | JSON → XLSX | Render a `WarmUpDocument` back to a styled .xlsx on disk. |
35
43
 
36
- The JSON schema is defined once in `src/schema/program.ts` using Zod and
37
- surfaces as:
44
+ The JSON schemas are defined once in `src/schema/program.ts` and
45
+ `src/schema/warmup.ts` using Zod and surface as:
38
46
 
39
47
  1. The TypeScript type used internally
40
48
  2. The runtime validator at each tool boundary
41
49
  3. The JSON Schema published as each tool's `inputSchema` so the LLM sees
42
50
  the full typed contract at discovery time
43
51
 
44
- ## JSON shape
52
+ All object schemas are strict: unknown fields are rejected with an error
53
+ rather than silently stripped, so callers authoring against a stale
54
+ contract fail loudly.
55
+
56
+ ## JSON shape — programs
45
57
 
46
58
  ```ts
47
59
  type Program = {
48
60
  client: string; // "Bobby Galli"
49
61
  programDate: string; // "4.26"
50
- weekDates?: (string | null)[]; // optional, length 6
51
62
  days: Day[];
52
63
  };
53
64
 
@@ -66,20 +77,21 @@ type Exercise = {
66
77
  name: string; // "2DB Alternating Bench Press"
67
78
  groupLabel?: string; // "1A", "1B", "2A"… matches /^\d+[A-Z]$/
68
79
  notes?: string;
69
- sets: (string | null)[][]; // [setIdx][weekIdx]; inner length 6
80
+ sets: (string | null)[][]; // [setIdx][weekIdx]; one entry per week (4 or 6)
81
+ weights?: (string | null)[][]; // Weight column, same shape as sets
70
82
  };
71
83
 
72
84
  type SupplementalNote = {
73
85
  row: number; // offset from "Sport Specific/" label row
74
- col: string; // "C" | "D" | … | "P"
86
+ col: string; // a week's Reps column: "D" | "F" | … (one per week)
75
87
  value: string;
76
88
  };
77
89
  ```
78
90
 
79
91
  ### Prescription cell grammar
80
92
 
81
- Olympia does **not** prescribe weights. Each `sets[setIdx][weekIdx]` is a
82
- single opaque string encoding **reps**, **time**, or **breaths**:
93
+ Each `sets[setIdx][weekIdx]` is a single opaque string encoding **reps**,
94
+ **time**, or **breaths**:
83
95
 
84
96
  | Format | Meaning |
85
97
  | --------------------- | ------------------------------------ |
@@ -90,10 +102,36 @@ single opaque string encoding **reps**, **time**, or **breaths**:
90
102
  | free-form strings | circuit-style instructions, OK too |
91
103
  | `null` | empty cell |
92
104
 
93
- The schema enforces shape (6 weeks per set, group label regex, allowed
94
- section kinds) but does **not** validate the grammar of each prescription
105
+ The schema enforces shape (1-6 weeks per set and the same count for every
106
+ set in a program, group label regex, allowed section kinds) but does **not** validate the grammar of each prescription
95
107
  string — Olympia's own files include free-form entries in some spots.
96
108
 
109
+ The `Date:` cells in each sheet's header row are **intentionally left
110
+ blank** — clients hand-write the week dates on the printed sheet, so the
111
+ schema has no field for them and the writer never fills them.
112
+
113
+ ### Weight column
114
+
115
+ Each week has a Weight cell beside its reps cell. Most are left empty for
116
+ the client to fill in by hand, but coaches type starting loads (`25`) and
117
+ equipment settings there too: a band colour (`Yel`), a box height
118
+ (`12" + 2 Pads`), a rack position (`#10`). `weights` carries those cells
119
+ as free text, in the same shape as `sets`, and is omitted when every cell
120
+ is empty.
121
+
122
+ ### Week count and sheet layout
123
+
124
+ Olympia programs run 4 or 6 weeks. The week count is the length of each
125
+ set row; the writer lays the sheet out with exactly that many week pairs
126
+ and puts the Notes column right after the last one (L for 4 weeks, P for
127
+ 6). The reader finds the table from its `Exercise | Reps | Weight …`
128
+ header row, so it handles both lengths and the two header positions seen
129
+ in coach-built files (row 4 or row 5).
130
+
131
+ Warm-up exercises listed inside a day sheet are closed by a `Warmup N`
132
+ label row; they decode into a `warmup` section and are written back the
133
+ same way.
134
+
97
135
  ### Supplemental area
98
136
 
99
137
  The bottom of every day sheet is a trainer-managed "Sport Specific /
@@ -107,6 +145,46 @@ drills, recovery work). The adapter:
107
145
  "Supplemental" labels) at the bottom of every day, then layers any
108
146
  supplied `supplementalNotes` on top.
109
147
 
148
+ ## JSON shape — warm-ups
149
+
150
+ ```ts
151
+ type WarmUpDocument = {
152
+ client: string; // "Bobby Galli" — same as companion program
153
+ warmUpDate: string; // "4.26" — matches the companion programDate
154
+ days: WarmUpDay[];
155
+ };
156
+
157
+ type WarmUpDay = {
158
+ name: string; // "Day 4" — matches a program day sheet name
159
+ sections: WarmUpSection[];
160
+ };
161
+
162
+ type WarmUpSection = {
163
+ title: string; // "Foam Roll", "Prep \ Activation X2 Rounds"
164
+ items: WarmUpItem[];
165
+ };
166
+
167
+ type WarmUpItem = {
168
+ text: string; // "Pecs :45E" — prescription inline, never parsed
169
+ note?: string; // "(^R.F.E on SB, F-Roller for Balance^)"
170
+ };
171
+ ```
172
+
173
+ The warm-up layout is days-as-columns on a single sheet (A, C, E, … with
174
+ empty gutter columns between), sections row-aligned across days. Section
175
+ headers are bold; exercise lines are plain with the prescription shorthand
176
+ embedded in the text (`:45E`, `X8br`, `X4ES` — trainer conventions, kept
177
+ opaque); note lines render in italics under their exercise. When decoding,
178
+ italic or fully-parenthesized lines are treated as notes attached to the
179
+ item above — so never author an item whose entire text is parenthesized.
180
+
181
+ ## Skill
182
+
183
+ `skills/olympia-authoring/SKILL.md` (shipped in the npm package) is an
184
+ agent-facing technical reference covering both formats: the schemas, the
185
+ prescription notation, the program/warm-up pairing rules, and the
186
+ decode → author → encode workflow.
187
+
110
188
  ## Installation
111
189
 
112
190
  ```bash
@@ -143,29 +221,38 @@ Claude or another MCP client, add an entry like:
143
221
  }
144
222
  ```
145
223
 
146
- Both tools take absolute paths to .xlsx files on the local filesystem.
224
+ All tools take absolute paths to .xlsx files on the local filesystem.
147
225
 
148
226
  ## Repo layout
149
227
 
150
228
  ```
151
229
  src/
152
- schema/program.ts # Zod schema — single source of truth
230
+ schema/
231
+ program.ts # Zod schema for programs — single source of truth
232
+ warmup.ts # Zod schema for warm-up documents
153
233
  reader/
234
+ cell-utils.ts # Shared ExcelJS cell-value helpers
154
235
  parse-workbook.ts # Workbook → Program JSON
155
236
  parse-day-sheet.ts # One sheet, section detection, supplemental capture
237
+ parse-warmup-workbook.ts # Warm-up workbook → WarmUpDocument JSON
156
238
  writer/
157
239
  build-workbook.ts # Program → ExcelJS workbook
240
+ build-warmup-workbook.ts # WarmUpDocument → ExcelJS workbook
158
241
  layout.ts # Column widths, row positions, logo anchor
159
242
  styles.ts # All borders/fonts/fills as constants
160
243
  stamp-exercise.ts # One exercise block (variable sets, group borders)
161
244
  supplemental-template.ts # Fixed bottom template + supplementalNotes overlay
162
245
  tools/
163
- xlsx-to-json.ts # MCP handler
164
- json-to-xlsx.ts # MCP handler
165
- server.ts # Bootstrap, registers both tools
246
+ program-xlsx-to-json.ts # MCP handler
247
+ program-json-to-xlsx.ts # MCP handler
248
+ warmup-xlsx-to-json.ts # MCP handler
249
+ warmup-json-to-xlsx.ts # MCP handler
250
+ server.ts # Bootstrap, registers all four tools
166
251
  assets/
167
252
  olympia-logo.png # Extracted from sample workbooks; embedded in output
168
253
 
254
+ skills/
255
+ olympia-authoring/SKILL.md # Agent-facing technical reference for both formats
169
256
  samples/ # Real Olympia .xlsx files used as round-trip fixtures
170
257
  scripts/ # Inspection helpers used during development
171
258
  tests/ # vitest — schema, reader, round-trip, MCP smoke
@@ -181,16 +268,33 @@ npm run typecheck # tsc --noEmit
181
268
 
182
269
  Coverage:
183
270
 
184
- - **`schema.test.ts`** — Zod rejects malformed input (week count, group
185
- label regex, section kinds, missing fields).
186
- - **`reader.test.ts`** — All three sample workbooks parse into
187
- schema-valid `Program` documents; spot checks on Bobby's first exercise,
188
- Giulianna's variable set counts, exercise notes, supplemental notes.
271
+ - **`schema.test.ts`** — Zod rejects malformed program input (week count,
272
+ group label regex, section kinds, missing fields).
273
+ - **`reader.test.ts`** — All three program samples parse into schema-valid
274
+ `Program` documents; spot checks on Bobby's first exercise, Giulianna's
275
+ variable set counts, exercise notes, supplemental notes.
276
+ - **`week-count.test.ts`** — A 4-week program is laid out with four week
277
+ pairs and round-trips; a hand-built 4-week sheet (header on row 4,
278
+ `Warmup N` labels, Notes in column L) decodes correctly.
279
+ - **`weights.test.ts`** — Weight-column cells are written beside their
280
+ reps, round-trip, and must match the shape of `sets`.
189
281
  - **`round-trip.test.ts`** — The primary determinism guarantee: for each
190
- sample, `parse → write → parse` produces JSON equal to the original.
282
+ program sample, `parse → write → parse` produces JSON equal to the
283
+ original.
284
+ - **`warmup-doc-schema.test.ts`** — Zod accept/reject tables for the
285
+ warm-up document schema.
286
+ - **`warmup-doc-reader.test.ts`** — The warm-up sample parses into a
287
+ schema-valid `WarmUpDocument` with verbatim spot checks (including the
288
+ note-attachment rule); warning paths for irregular layouts.
289
+ - **`warmup-doc-writer.test.ts`** — Day/section/item/note styling and
290
+ cross-day section alignment, verified by re-reading the written file.
291
+ - **`warmup-doc-round-trip.test.ts`** — Warm-up determinism: the sample
292
+ and an authored document both survive `parse → write → parse` /
293
+ `write → parse` unchanged.
191
294
  - **`mcp-smoke.test.ts`** — End-to-end MCP server check via the in-memory
192
- transport: tool discovery, round-trip via `client.callTool`, schema
193
- rejection of malformed input at the SDK boundary.
295
+ transport: tool discovery (all four tools), round-trips via
296
+ `client.callTool` for both document types, schema rejection of malformed
297
+ input at the SDK boundary.
194
298
 
195
299
  ## Determinism
196
300
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- export { Program, Day, Section, SectionKind, Exercise, SetRow, Cell, SupplementalNote, XlsxToJsonInput, XlsxToJsonOutput, JsonToXlsxInput, JsonToXlsxOutput, } from "./schema/program.js";
1
+ export { Program, Day, Section, SectionKind, Exercise, SetRow, Cell, WeightCell, SupplementalNote, ProgramXlsxToJsonInput, ProgramXlsxToJsonOutput, ProgramJsonToXlsxInput, ProgramJsonToXlsxOutput, } from "./schema/program.js";
2
+ export { WarmUpDocument, WarmUpDay, WarmUpSection, WarmUpItem, WarmUpXlsxToJsonInput, WarmUpXlsxToJsonOutput, WarmUpJsonToXlsxInput, WarmUpJsonToXlsxOutput, } from "./schema/warmup.js";
2
3
  export { createServer } from "./server.js";
3
- export { parseWorkbook } from "./reader/parse-workbook.js";
4
- export { buildWorkbook } from "./writer/build-workbook.js";
4
+ export { parseProgramWorkbook } from "./reader/parse-workbook.js";
5
+ export { buildProgramWorkbook } from "./writer/build-workbook.js";
6
+ export { parseWarmUpWorkbook } from "./reader/parse-warmup-workbook.js";
7
+ export { buildWarmUpWorkbook } from "./writer/build-warmup-workbook.js";
5
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,OAAO,EACP,GAAG,EACH,OAAO,EACP,WAAW,EACX,QAAQ,EACR,MAAM,EACN,IAAI,EACJ,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,OAAO,EACP,GAAG,EACH,OAAO,EACP,WAAW,EACX,QAAQ,EACR,MAAM,EACN,IAAI,EACJ,UAAU,EACV,gBAAgB,EAChB,sBAAsB,EACtB,uBAAuB,EACvB,sBAAsB,EACtB,uBAAuB,GACxB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,cAAc,EACd,SAAS,EACT,aAAa,EACb,UAAU,EACV,qBAAqB,EACrB,sBAAsB,EACtB,qBAAqB,EACrB,sBAAsB,GACvB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AACxE,OAAO,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC"}
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // Programmatic entry point. The package's primary use is the MCP CLI
2
2
  // (registered under the `bin` field), but the schema and server factory
3
3
  // are also exported for callers that want to embed or compose the adapter.
4
- export { Program, Day, Section, SectionKind, Exercise, SetRow, Cell, SupplementalNote, XlsxToJsonInput, XlsxToJsonOutput, JsonToXlsxInput, JsonToXlsxOutput, } from "./schema/program.js";
4
+ export { Program, Day, Section, SectionKind, Exercise, SetRow, Cell, WeightCell, SupplementalNote, ProgramXlsxToJsonInput, ProgramXlsxToJsonOutput, ProgramJsonToXlsxInput, ProgramJsonToXlsxOutput, } from "./schema/program.js";
5
+ export { WarmUpDocument, WarmUpDay, WarmUpSection, WarmUpItem, WarmUpXlsxToJsonInput, WarmUpXlsxToJsonOutput, WarmUpJsonToXlsxInput, WarmUpJsonToXlsxOutput, } from "./schema/warmup.js";
5
6
  export { createServer } from "./server.js";
6
- export { parseWorkbook } from "./reader/parse-workbook.js";
7
- export { buildWorkbook } from "./writer/build-workbook.js";
7
+ export { parseProgramWorkbook } from "./reader/parse-workbook.js";
8
+ export { buildProgramWorkbook } from "./writer/build-workbook.js";
9
+ export { parseWarmUpWorkbook } from "./reader/parse-warmup-workbook.js";
10
+ export { buildWarmUpWorkbook } from "./writer/build-warmup-workbook.js";
8
11
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,wEAAwE;AACxE,2EAA2E;AAE3E,OAAO,EACL,OAAO,EACP,GAAG,EACH,OAAO,EACP,WAAW,EACX,QAAQ,EACR,MAAM,EACN,IAAI,EACJ,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,wEAAwE;AACxE,2EAA2E;AAE3E,OAAO,EACL,OAAO,EACP,GAAG,EACH,OAAO,EACP,WAAW,EACX,QAAQ,EACR,MAAM,EACN,IAAI,EACJ,UAAU,EACV,gBAAgB,EAChB,sBAAsB,EACtB,uBAAuB,EACvB,sBAAsB,EACtB,uBAAuB,GACxB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,cAAc,EACd,SAAS,EACT,aAAa,EACb,UAAU,EACV,qBAAqB,EACrB,sBAAsB,EACtB,qBAAqB,EACrB,sBAAsB,GACvB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AACxE,OAAO,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type ExcelJS from "exceljs";
2
+ /**
3
+ * Best-effort plain-text extraction from an ExcelJS cell value: handles
4
+ * strings, numbers, booleans, dates, rich text, hyperlink-style `{ text }`
5
+ * objects, and formula results. Anything else becomes "".
6
+ */
7
+ export declare function cellText(value: ExcelJS.CellValue | undefined | null): string;
8
+ export declare function cellOrNull(value: ExcelJS.CellValue | undefined | null): string | null;
9
+ export declare function isMergeContinuation(cell: ExcelJS.Cell): boolean;
10
+ //# sourceMappingURL=cell-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cell-utils.d.ts","sourceRoot":"","sources":["../../src/reader/cell-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAEnC;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAmB5E;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,IAAI,GAC1C,MAAM,GAAG,IAAI,CAGf;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,GAAG,OAAO,CAI/D"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Best-effort plain-text extraction from an ExcelJS cell value: handles
3
+ * strings, numbers, booleans, dates, rich text, hyperlink-style `{ text }`
4
+ * objects, and formula results. Anything else becomes "".
5
+ */
6
+ export function cellText(value) {
7
+ if (value === null || value === undefined)
8
+ return "";
9
+ if (typeof value === "string")
10
+ return value;
11
+ if (typeof value === "number" || typeof value === "boolean")
12
+ return String(value);
13
+ if (value instanceof Date)
14
+ return value.toISOString();
15
+ if (typeof value === "object") {
16
+ if ("richText" in value && Array.isArray(value.richText)) {
17
+ return value.richText
18
+ .map((r) => r.text)
19
+ .join("");
20
+ }
21
+ if ("text" in value && typeof value.text === "string") {
22
+ return value.text;
23
+ }
24
+ if ("result" in value) {
25
+ return cellText(value.result);
26
+ }
27
+ }
28
+ return "";
29
+ }
30
+ export function cellOrNull(value) {
31
+ const t = cellText(value).trim();
32
+ return t === "" ? null : t;
33
+ }
34
+ export function isMergeContinuation(cell) {
35
+ if (!cell.isMerged)
36
+ return false;
37
+ const master = cell.master;
38
+ return master !== undefined && master.address !== cell.address;
39
+ }
40
+ //# sourceMappingURL=cell-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cell-utils.js","sourceRoot":"","sources":["../../src/reader/cell-utils.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,UAAU,QAAQ,CAAC,KAA2C;IAClE,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IACrD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;IAClF,IAAI,KAAK,YAAY,IAAI;QAAE,OAAO,KAAK,CAAC,WAAW,EAAE,CAAC;IACtD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,IAAI,UAAU,IAAI,KAAK,IAAI,KAAK,CAAC,OAAO,CAAE,KAA+B,CAAC,QAAQ,CAAC,EAAE,CAAC;YACpF,OAAQ,KAA0C,CAAC,QAAQ;iBACxD,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;iBAClB,IAAI,CAAC,EAAE,CAAC,CAAC;QACd,CAAC;QACD,IAAI,MAAM,IAAI,KAAK,IAAI,OAAQ,KAA2B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC7E,OAAQ,KAA0B,CAAC,IAAI,CAAC;QAC1C,CAAC;QACD,IAAI,QAAQ,IAAI,KAAK,EAAE,CAAC;YACtB,OAAO,QAAQ,CAAE,KAAuC,CAAC,MAAM,CAAC,CAAC;QACnE,CAAC;IACH,CAAC;IACD,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,UAAU,CACxB,KAA2C;IAE3C,MAAM,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC;IACjC,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,IAAkB;IACpD,IAAI,CAAC,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IACjC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;IAC3B,OAAO,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,OAAO,KAAK,IAAI,CAAC,OAAO,CAAC;AACjE,CAAC"}
@@ -2,7 +2,6 @@ import ExcelJS from "exceljs";
2
2
  import type { SupplementalNote, Day } from "../schema/program.js";
3
3
  export interface ParsedDay {
4
4
  sections: Day["sections"];
5
- weekDates?: (string | null)[];
6
5
  supplementalNotes?: SupplementalNote[];
7
6
  warnings: string[];
8
7
  }
@@ -1 +1 @@
1
- {"version":3,"file":"parse-day-sheet.d.ts","sourceRoot":"","sources":["../../src/reader/parse-day-sheet.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,SAAS,CAAC;AAC9B,OAAO,KAAK,EACV,gBAAgB,EAEhB,GAAG,EAIJ,MAAM,sBAAsB,CAAC;AAE9B,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IAC1B,SAAS,CAAC,EAAE,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IAC9B,iBAAiB,CAAC,EAAE,gBAAgB,EAAE,CAAC;IACvC,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAmFD,wBAAgB,aAAa,CAAC,EAAE,EAAE,OAAO,CAAC,SAAS,GAAG,SAAS,CAsG9D"}
1
+ {"version":3,"file":"parse-day-sheet.d.ts","sourceRoot":"","sources":["../../src/reader/parse-day-sheet.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,SAAS,CAAC;AAC9B,OAAO,KAAK,EACV,gBAAgB,EAEhB,GAAG,EAIJ,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IAC1B,iBAAiB,CAAC,EAAE,gBAAgB,EAAE,CAAC;IACvC,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAyFD,wBAAgB,aAAa,CAAC,EAAE,EAAE,OAAO,CAAC,SAAS,GAAG,SAAS,CA6H9D"}
@@ -1,12 +1,10 @@
1
- const FIRST_BODY_ROW = 7;
2
- const WEEK_COLS = ["D", "F", "H", "J", "L", "N"];
3
- const SECTION_LABELS = new Map([
4
- ["warmup", "warmup"],
5
- ["sport specific", "sportSpecific"],
6
- ["sport specific/", "sportSpecific"],
7
- ["supplemental", "supplemental"],
8
- ["conditioning", "conditioning"],
9
- ]);
1
+ import { cellText, cellOrNull, isMergeContinuation } from "./cell-utils.js";
2
+ const MAX_WEEK_COUNT = 6;
3
+ const FIRST_WEEK_COL_INDEX = 4; // column D
4
+ const HEADER_SEARCH_ROWS = 12;
5
+ // Layout of sheets written by this package, used when a sheet has no
6
+ // recognizable "Exercise | Reps | Weight …" header row.
7
+ const DEFAULT_HEADER_ROW = 5;
10
8
  const SUPPLEMENTAL_TRIGGER_LABELS = new Set([
11
9
  "sport specific",
12
10
  "sport specific/",
@@ -14,70 +12,62 @@ const SUPPLEMENTAL_TRIGGER_LABELS = new Set([
14
12
  "conditioning",
15
13
  "circuit",
16
14
  ]);
17
- // Columns scanned for supplementalNotes. Excludes A/B (cosmetic narrow margins)
18
- // and Q (rarely used).
19
- const SUPPLEMENTAL_COLS = ["C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P"];
20
15
  function isGroupLabel(s) {
21
16
  return /^\d+[A-Z]$/.test(s);
22
17
  }
23
- function cellText(value) {
24
- if (value === null || value === undefined)
25
- return "";
26
- if (typeof value === "string")
27
- return value;
28
- if (typeof value === "number" || typeof value === "boolean")
29
- return String(value);
30
- if (value instanceof Date)
31
- return value.toISOString();
32
- if (typeof value === "object") {
33
- if ("richText" in value && Array.isArray(value.richText)) {
34
- return value.richText
35
- .map((r) => r.text)
36
- .join("");
37
- }
38
- if ("text" in value && typeof value.text === "string") {
39
- return value.text;
40
- }
41
- if ("result" in value) {
42
- return cellText(value.result);
18
+ /** "Warmup 1", "Warm Up 2", "Warm-up 3": the label row closing an in-sheet warm-up exercise. */
19
+ function isWarmupLabel(s) {
20
+ return /^warm[\s-]?up\s*#?\d+$/i.test(s);
21
+ }
22
+ /**
23
+ * Locate the exercise table from its header row ("Exercise | Reps | Weight |
24
+ * Reps | Weight | …"). Olympia's sheets vary: the header sits on row 4 or 5
25
+ * and a program runs 4 or 6 weeks, with the Notes column immediately right
26
+ * of the last week pair. The body starts two rows below the header.
27
+ */
28
+ function detectLayout(ws) {
29
+ let headerRow = DEFAULT_HEADER_ROW;
30
+ let weekCount = MAX_WEEK_COUNT;
31
+ for (let r = 1; r <= HEADER_SEARCH_ROWS; r++) {
32
+ const row = ws.getRow(r);
33
+ if (cellText(row.getCell("C").value).trim().toLowerCase() !== "exercise")
34
+ continue;
35
+ let found = 0;
36
+ while (found < MAX_WEEK_COUNT &&
37
+ cellText(row.getCell(FIRST_WEEK_COL_INDEX + 2 * found).value).trim().toLowerCase() === "reps") {
38
+ found++;
43
39
  }
40
+ // An "Exercise" cell without Reps headers beside it (a title, a note)
41
+ // is not the header row; keep looking.
42
+ if (found === 0)
43
+ continue;
44
+ headerRow = r;
45
+ weekCount = found;
46
+ break;
44
47
  }
45
- return "";
46
- }
47
- function cellOrNull(value) {
48
- const t = cellText(value).trim();
49
- return t === "" ? null : t;
50
- }
51
- function isMergeContinuation(cell) {
52
- if (!cell.isMerged)
53
- return false;
54
- const master = cell.master;
55
- return master !== undefined && master.address !== cell.address;
48
+ const weekCols = Array.from({ length: weekCount }, (_, i) => FIRST_WEEK_COL_INDEX + 2 * i);
49
+ return {
50
+ firstBodyRow: headerRow + 2,
51
+ weekCols,
52
+ notesCol: FIRST_WEEK_COL_INDEX + 2 * weekCount,
53
+ };
56
54
  }
57
- function readWeekRow(row) {
58
- return WEEK_COLS.map((col) => cellOrNull(row.getCell(col).value));
55
+ function readWeekRow(row, layout) {
56
+ return layout.weekCols.map((col) => cellOrNull(row.getCell(col).value));
59
57
  }
60
- function readWeekDates(row) {
61
- return WEEK_COLS.map((col) => {
62
- const t = cellText(row.getCell(col).value).trim();
63
- if (t === "" || t.toLowerCase() === "date:")
64
- return null;
65
- return t;
66
- });
58
+ /** The Weight cell sits immediately right of each week's Reps cell. */
59
+ function readWeightRow(row, layout) {
60
+ return layout.weekCols.map((col) => cellOrNull(row.getCell(col + 1).value));
67
61
  }
68
62
  export function parseDaySheet(ws) {
69
63
  const warnings = [];
70
64
  const sections = [];
65
+ const layout = detectLayout(ws);
71
66
  let current = { kind: "main", exercises: [] };
72
67
  let exercise = null;
73
- let weekDates;
74
- const dateRow = ws.getRow(2);
75
- if (dateRow) {
76
- const dates = readWeekDates(dateRow);
77
- if (dates.some((d) => d !== null))
78
- weekDates = dates;
79
- }
80
- const flushExercise = (groupLabel) => {
68
+ // An exercise closed by a "Warmup N" label belongs to the warmup section;
69
+ // every other exercise belongs to main. A change of kind starts a new section.
70
+ const flushExercise = (groupLabel, kind = "main") => {
81
71
  if (!exercise)
82
72
  return;
83
73
  const ex = {
@@ -88,9 +78,19 @@ export function parseDaySheet(ws) {
88
78
  ex.groupLabel = groupLabel;
89
79
  if (exercise.notes)
90
80
  ex.notes = exercise.notes;
81
+ if (exercise.weights.some((row) => row.some((cell) => cell !== null))) {
82
+ ex.weights = exercise.weights;
83
+ }
84
+ if (current.kind !== kind) {
85
+ if (current.exercises.length > 0) {
86
+ sections.push({ kind: current.kind, exercises: current.exercises });
87
+ }
88
+ current = { kind, exercises: [] };
89
+ }
91
90
  current.exercises.push(ex);
92
91
  exercise = null;
93
92
  };
93
+ const noteOf = (row) => cellText(row.getCell(layout.notesCol).value).trim();
94
94
  const flushSection = () => {
95
95
  flushExercise();
96
96
  sections.push({ kind: current.kind, exercises: current.exercises });
@@ -98,7 +98,7 @@ export function parseDaySheet(ws) {
98
98
  const lastRow = ws.actualRowCount > 0 ? ws.rowCount : 0;
99
99
  let supplementalStartRow;
100
100
  let supplementalNotes;
101
- for (let r = FIRST_BODY_ROW; r <= lastRow; r++) {
101
+ for (let r = layout.firstBodyRow; r <= lastRow; r++) {
102
102
  const row = ws.getRow(r);
103
103
  const cCell = row.getCell("C");
104
104
  const cText = cellText(cCell.value).trim();
@@ -107,26 +107,41 @@ export function parseDaySheet(ws) {
107
107
  if (SUPPLEMENTAL_TRIGGER_LABELS.has(cLower)) {
108
108
  flushSection();
109
109
  supplementalStartRow = r;
110
- supplementalNotes = captureSupplementalNotes(ws, r, lastRow);
110
+ supplementalNotes = captureSupplementalNotes(ws, r, lastRow, layout);
111
111
  break;
112
112
  }
113
- // Group label terminates an exercise block.
114
- if (cText !== "" && isGroupLabel(cText) && !isMergeContinuation(cCell)) {
113
+ // A group label ("1A") or warm-up label ("Warmup 1") terminates an exercise block.
114
+ const warmupLabel = isWarmupLabel(cText);
115
+ if (cText !== "" && (isGroupLabel(cText) || warmupLabel) && !isMergeContinuation(cCell)) {
115
116
  if (!exercise) {
116
- warnings.push(`row ${r}: group label "${cText}" without a preceding exercise name`);
117
+ warnings.push(`row ${r}: label "${cText}" without a preceding exercise name`);
117
118
  continue;
118
119
  }
119
- exercise.sets.push(readWeekRow(row));
120
- const pNote = cellText(row.getCell("P").value).trim();
120
+ // A label row with nothing in it, under rows that do hold a
121
+ // prescription, is only there to carry the label of a one-set
122
+ // exercise — it is not a set.
123
+ const labelSets = readWeekRow(row, layout);
124
+ const labelWeights = readWeightRow(row, layout);
125
+ const labelRowEmpty = [...labelSets, ...labelWeights].every((cell) => cell === null);
126
+ const blockHasContent = [...exercise.sets, ...exercise.weights].some((cells) => cells.some((cell) => cell !== null));
127
+ if (!(labelRowEmpty && blockHasContent)) {
128
+ exercise.sets.push(labelSets);
129
+ exercise.weights.push(labelWeights);
130
+ }
131
+ const pNote = noteOf(row);
121
132
  if (pNote && !exercise.notes)
122
133
  exercise.notes = pNote;
123
- flushExercise(cText);
134
+ if (warmupLabel)
135
+ flushExercise(undefined, "warmup");
136
+ else
137
+ flushExercise(cText);
124
138
  continue;
125
139
  }
126
140
  // Continuation of a merged exercise name?
127
141
  if (isMergeContinuation(cCell) && exercise) {
128
- exercise.sets.push(readWeekRow(row));
129
- const pNote = cellText(row.getCell("P").value).trim();
142
+ exercise.sets.push(readWeekRow(row, layout));
143
+ exercise.weights.push(readWeightRow(row, layout));
144
+ const pNote = noteOf(row);
130
145
  if (pNote && !exercise.notes)
131
146
  exercise.notes = pNote;
132
147
  continue;
@@ -136,9 +151,10 @@ export function parseDaySheet(ws) {
136
151
  flushExercise();
137
152
  exercise = {
138
153
  name: cText,
139
- sets: [readWeekRow(row)],
154
+ sets: [readWeekRow(row, layout)],
155
+ weights: [readWeightRow(row, layout)],
140
156
  };
141
- const pNote = cellText(row.getCell("P").value).trim();
157
+ const pNote = noteOf(row);
142
158
  if (pNote)
143
159
  exercise.notes = pNote;
144
160
  continue;
@@ -153,7 +169,6 @@ export function parseDaySheet(ws) {
153
169
  }
154
170
  return {
155
171
  sections: nonEmpty,
156
- ...(weekDates ? { weekDates } : {}),
157
172
  ...(supplementalNotes && supplementalNotes.length > 0
158
173
  ? { supplementalNotes }
159
174
  : {}),
@@ -171,11 +186,16 @@ export function parseDaySheet(ws) {
171
186
  * multiple cells appears once). The trigger labels themselves are skipped
172
187
  * since they're added back by the writer's fixed template.
173
188
  */
174
- function captureSupplementalNotes(ws, supplementalStartRow, lastRow) {
189
+ function captureSupplementalNotes(ws, supplementalStartRow, lastRow, layout) {
175
190
  const out = [];
191
+ // Scan from column C through the Notes column. Excludes A/B (cosmetic
192
+ // narrow margins) and anything right of Notes.
193
+ const cols = [];
194
+ for (let i = 3; i <= layout.notesCol; i++)
195
+ cols.push(String.fromCharCode(64 + i));
176
196
  for (let r = supplementalStartRow; r <= lastRow; r++) {
177
197
  const row = ws.getRow(r);
178
- for (const col of SUPPLEMENTAL_COLS) {
198
+ for (const col of cols) {
179
199
  const cell = row.getCell(col);
180
200
  if (isMergeContinuation(cell))
181
201
  continue;