[OCaml] High Intensity Training Online
1
(** See {!module:Hevy_csv} for the contract.
2
3
The parse runs in two stages. First it maps the header to column indices,
4
failing early on a missing column. Then it walks the data rows once: each
5
row becomes a set for some workout, or a batch warning. Rows group into
6
workouts by their exact [(title, start_time, end_time)] triple in
7
first-appearance order, and warnings stay in source order. The adapter never
8
rebuilds the source text. The text flows verbatim into the batch. *)
9
10
open Hito_app
11
module WI = Workout_import
12
13
type error =
14
| Malformed_csv of { record : int; field : int; detail : string }
15
| Missing_column of string
16
| Empty_file
17
18
let pp_error ppf = function
19
| Malformed_csv { record; field; detail } ->
20
Format.fprintf ppf "malformed CSV at record %d, field %d: %s" record field
21
detail
22
| Missing_column name -> Format.fprintf ppf "missing column %S" name
23
| Empty_file -> Format.pp_print_string ppf "empty file"
24
25
(* The strict native schema. Every column must be present. The order here is
26
canonical: a missing-column error names the first absent column in this
27
order, not the order the header happens to use. *)
28
let canonical_columns =
29
[
30
"title";
31
"start_time";
32
"end_time";
33
"description";
34
"exercise_title";
35
"superset_id";
36
"exercise_notes";
37
"set_index";
38
"set_type";
39
"weight_kg";
40
"reps";
41
"distance_km";
42
"duration_seconds";
43
"rpe";
44
]
45
46
(* Column indices resolved from the header, keyed by canonical name. Built once,
47
then read for every row. *)
48
type columns = (string * int) list
49
50
(* Maps the header row to column indices. The header order may vary, so each
51
name is looked up by value. The first canonical column absent from the header
52
fails the whole parse. *)
53
let resolve_columns (header : string list) : (columns, error) result =
54
let indexed = List.mapi (fun i name -> (name, i)) header in
55
let rec go acc = function
56
| [] -> Ok (List.rev acc)
57
| name :: rest -> (
58
match List.assoc_opt name indexed with
59
| Some i -> go ((name, i) :: acc) rest
60
| None -> Error (Missing_column name))
61
in
62
go [] canonical_columns
63
64
(* A row is a plain string list. This reads one column by canonical name,
65
returning [""] for a short row rather than raising. The column is guaranteed
66
present in [cols] once [resolve_columns] succeeds. *)
67
let field (cols : columns) (row : string list) name =
68
match List.assoc_opt name cols with
69
| None -> ""
70
| Some i -> ( match List.nth_opt row i with Some v -> v | None -> "")
71
72
(* {1 Datetime}
73
74
Hevy writes local wall time as [DD Mon YYYY, HH:MM] with an English month
75
abbreviation. The adapter converts to UTC seconds using the caller's fixed
76
offset: UTC = civil - offset. A round-trip through [days_from_civil]
77
validates the calendar date, so the adapter rejects an impossible date such
78
as 31 Feb rather than normalising it. *)
79
80
let months =
81
[|
82
"Jan";
83
"Feb";
84
"Mar";
85
"Apr";
86
"May";
87
"Jun";
88
"Jul";
89
"Aug";
90
"Sep";
91
"Oct";
92
"Nov";
93
"Dec";
94
|]
95
96
let month_index abbrev =
97
let rec go i =
98
if i >= Array.length months then None
99
else if String.equal months.(i) abbrev then Some (i + 1)
100
else go (i + 1)
101
in
102
go 0
103
104
(* Days from the civil date 1970-01-01 to [y]-[m]-[d]. Howard Hinnant's
105
algorithm, valid for the proleptic Gregorian calendar. Used both to compute
106
the timestamp and, by round-trip, to confirm the date is real. *)
107
let days_from_civil y m d =
108
let y = if m <= 2 then y - 1 else y in
109
let era = (if y >= 0 then y else y - 399) / 400 in
110
let yoe = y - (era * 400) in
111
let doy = (((153 * if m > 2 then m - 3 else m + 9) + 2) / 5) + d - 1 in
112
let doe = (yoe * 365) + (yoe / 4) - (yoe / 100) + doy in
113
(era * 146097) + doe - 719468
114
115
(* The inverse of [days_from_civil], for the round-trip check. *)
116
let civil_from_days z =
117
let z = z + 719468 in
118
let era = (if z >= 0 then z else z - 146096) / 146097 in
119
let doe = z - (era * 146097) in
120
let yoe = (doe - (doe / 1460) + (doe / 36524) - (doe / 146096)) / 365 in
121
let y = yoe + (era * 400) in
122
let doy = doe - ((365 * yoe) + (yoe / 4) - (yoe / 100)) in
123
let mp = ((5 * doy) + 2) / 153 in
124
let d = doy - (((153 * mp) + 2) / 5) + 1 in
125
let m = if mp < 10 then mp + 3 else mp - 9 in
126
((if m <= 2 then y + 1 else y), m, d)
127
128
let is_digits s =
129
String.length s > 0 && String.for_all (fun c -> c >= '0' && c <= '9') s
130
131
(* Parses one Hevy datetime to UTC seconds at the given offset. Returns [None]
132
on any deviation from the exact [DD Mon YYYY, HH:MM] shape, an unknown month,
133
or a date that fails the calendar round-trip. *)
134
let parse_datetime ~utc_offset_seconds s =
135
match String.split_on_char ',' s with
136
| [ date_part; time_part ] -> (
137
let date_fields = String.split_on_char ' ' (String.trim date_part) in
138
let time_fields = String.split_on_char ':' (String.trim time_part) in
139
match (date_fields, time_fields) with
140
| [ dd; mon; yyyy ], [ hh; mm ]
141
when is_digits dd && is_digits yyyy && is_digits hh && is_digits mm -> (
142
match month_index mon with
143
| None -> None
144
| Some month ->
145
let day = int_of_string dd in
146
let year = int_of_string yyyy in
147
let hour = int_of_string hh in
148
let minute = int_of_string mm in
149
if
150
day < 1 || day > 31 || hour > 23 || minute > 59
151
|| String.length yyyy <> 4
152
then None
153
else
154
let days = days_from_civil year month day in
155
(* Reject impossible calendar dates by round-trip. *)
156
if civil_from_days days <> (year, month, day) then None
157
else
158
let civil_seconds =
159
(days * 86_400) + (hour * 3_600) + (minute * 60)
160
in
161
Some (civil_seconds - utc_offset_seconds))
162
| _ -> None)
163
| _ -> None
164
165
(* {1 Set types} *)
166
167
(* Hevy set types the adapter understands. [Warmup] never reaches a set: it
168
becomes a [Warmup_dropped] warning. Any other value is an unsupported row. *)
169
type parsed_set_type = Warmup | Working of WI.source_set_type
170
171
let parse_set_type s =
172
match String.lowercase_ascii (String.trim s) with
173
| "warmup" -> Some Warmup
174
| "normal" -> Some (Working WI.Normal)
175
| "failure" -> Some (Working WI.Failure)
176
| "dropset" -> Some (Working WI.Dropset)
177
| _ -> None
178
179
(* {1 Row outcome}
180
181
Each data row resolves to exactly one of: a set belonging to a workout keyed
182
by its datetime triple, a warning, or — when the datetimes are invalid and
183
the row cannot be grouped — a batch-level warning with no workout. *)
184
185
(* A parsed superset id: absent, present, or malformed. *)
186
type superset = Absent | Present of int | Invalid
187
188
let parse_superset s =
189
let s = String.trim s in
190
if String.equal s "" then Absent
191
else match int_of_string_opt s with Some i -> Present i | None -> Invalid
192
193
(* A finite float, rejecting NaN and the infinities that [float_of_string]
194
otherwise accepts. *)
195
let finite_float_opt s =
196
match float_of_string_opt (String.trim s) with
197
| Some f when Float.is_finite f -> Some f
198
| _ -> None
199
200
(* A load: finite and nonnegative. Zero is a bodyweight movement. This is the
201
rule {!Evidence.Stimulus.Effort.make} enforces, applied at the trust
202
boundary so a bad row becomes a warning, never a rejected effort later. *)
203
let load_opt s =
204
match finite_float_opt s with Some f when f >= 0. -> Some f | _ -> None
205
206
let nonneg_int_opt s =
207
match int_of_string_opt (String.trim s) with
208
| Some i when i >= 0 -> Some i
209
| _ -> None
210
211
(* A rep count: at least one. A set taken to failure has at least one rep,
212
which is the rule {!Evidence.Stimulus.Effort.make} enforces. *)
213
let reps_opt s =
214
match int_of_string_opt (String.trim s) with
215
| Some i when i >= 1 -> Some i
216
| _ -> None
217
218
(* The grouping key: the exact triple that identifies a workout. *)
219
type key = string * string * string
220
221
(* One classified row, before workouts are assembled. *)
222
type row_outcome =
223
| Set of { key : key; started : int; ended : int; set : WI.source_set }
224
| Warning of WI.warning
225
226
(* Classifies one data row at 1-based source [row]. The header is row 1, so the
227
first data row is row 2. The datetime triple keys the workout; invalid or
228
reversed datetimes drop the row. A warmup is dropped with its own warning.
229
Any field the strict schema needs but the row lacks turns the row into an
230
[Unsupported_row] warning, with a concise reason, never a guessed set. *)
231
let classify_row ~utc_offset_seconds cols ~row (fields : string list) :
232
row_outcome =
233
let get = field cols fields in
234
let title = get "title" in
235
let start_time = get "start_time" in
236
let end_time = get "end_time" in
237
let exercise_title = get "exercise_title" in
238
let exercise_name =
239
if String.equal (String.trim exercise_title) "" then None
240
else Some exercise_title
241
in
242
let warn reason =
243
Warning { row; exercise_name; kind = WI.Unsupported_row reason }
244
in
245
match parse_set_type (get "set_type") with
246
| None -> warn ("unknown set type " ^ get "set_type")
247
| Some Warmup -> Warning { row; exercise_name; kind = WI.Warmup_dropped }
248
| Some (Working set_type) -> (
249
match
250
( parse_datetime ~utc_offset_seconds start_time,
251
parse_datetime ~utc_offset_seconds end_time )
252
with
253
| None, _ -> warn "invalid start_time"
254
| _, None -> warn "invalid end_time"
255
| Some started, Some ended -> (
256
if ended < started then warn "end_time precedes start_time"
257
else if String.equal (String.trim exercise_title) "" then
258
warn "missing exercise_title"
259
else
260
match parse_superset (get "superset_id") with
261
| Invalid -> warn ("invalid superset_id " ^ get "superset_id")
262
| superset_id -> (
263
let superset_id =
264
match superset_id with
265
| Present i -> Some i
266
| Absent -> None
267
| Invalid -> None (* unreachable, handled above *)
268
in
269
match
270
( nonneg_int_opt (get "set_index"),
271
load_opt (get "weight_kg"),
272
reps_opt (get "reps") )
273
with
274
| None, _, _ -> warn "invalid set_index"
275
| _, None, _ ->
276
warn "missing, negative, or non-finite weight_kg"
277
| _, _, None -> warn "missing reps or fewer than one"
278
| Some set_index, Some weight_kg, Some reps ->
279
let set : WI.source_set =
280
{
281
row;
282
exercise_name = exercise_title;
283
set_index;
284
set_type;
285
weight_kg;
286
reps;
287
superset_id;
288
}
289
in
290
Set
291
{
292
key = (title, start_time, end_time);
293
started;
294
ended;
295
set;
296
})))
297
298
(* {1 Grouping}
299
300
Sets group into workouts by their key, in first-appearance order. A row whose
301
datetimes were valid contributes its set here; the workout keeps the first
302
[started]/[ended] it saw for that key. Warnings never group — they are
303
collected separately in source order. *)
304
305
type group = {
306
title : string;
307
started : int;
308
ended : int;
309
mutable sets_rev : WI.source_set list;
310
}
311
312
(* Folds classified rows into ordered groups and an ordered warning list. Groups
313
keep first-appearance order; within a group, sets keep source order. *)
314
let assemble outcomes =
315
let groups_rev = ref [] in
316
let table : (key, group) Hashtbl.t = Hashtbl.create 16 in
317
let warnings_rev = ref [] in
318
List.iter
319
(fun outcome ->
320
match outcome with
321
| Warning w -> warnings_rev := w :: !warnings_rev
322
| Set { key; started; ended; set } -> (
323
match Hashtbl.find_opt table key with
324
| Some g -> g.sets_rev <- set :: g.sets_rev
325
| None ->
326
let title, _, _ = key in
327
let g = { title; started; ended; sets_rev = [ set ] } in
328
Hashtbl.add table key g;
329
groups_rev := g :: !groups_rev))
330
outcomes;
331
(List.rev !groups_rev, List.rev !warnings_rev)
332
333
(* Builds one imported workout from a group. [make_workout] can still reject the
334
times with [Invalid_time]. The classifier already dropped reversed rows, so a
335
surviving group should not reach that rejection. The adapter still converts
336
any such rejection into a batch warning rather than raising. *)
337
let build_workout ~batch_id ~index (g : group) : (WI.workout, WI.warning) result
338
=
339
let id =
340
WI.workout_id (WI.batch_id_to_string batch_id ^ ":w" ^ string_of_int index)
341
in
342
let sets = List.rev g.sets_rev in
343
match
344
WI.make_workout ~id ~title:g.title
345
~started_at:(Recovery.timestamp_of_unix_seconds g.started)
346
~ended_at:(Recovery.timestamp_of_unix_seconds g.ended)
347
~sets
348
with
349
| Ok w -> Ok w
350
| Error _ ->
351
(* A whole group with reversed times; report at batch level. *)
352
Error
353
{
354
WI.row = 0;
355
exercise_name = None;
356
kind = WI.Unsupported_row ("workout " ^ g.title ^ " has invalid time");
357
}
358
359
let parse ~batch_id ~fingerprint ~imported_at ~utc_offset_seconds source =
360
(* Parse the CSV text. [strip:false] keeps RFC4180 fidelity. It preserves
361
quoted commas and newlines. A malformed export raises [Csv.Failure]. *)
362
match
363
try Ok (Csv.input_all (Csv.of_string ~strip:false source))
364
with Csv.Failure (record, field, detail) ->
365
Error (Malformed_csv { record; field; detail })
366
with
367
| Error _ as e -> e
368
| Ok [] -> Error Empty_file
369
| Ok (header :: data_rows) -> (
370
match resolve_columns header with
371
| Error _ as e -> e
372
| Ok cols ->
373
(* Row 1 is the header, so the first data row is row 2. *)
374
let outcomes =
375
List.mapi
376
(fun i fields ->
377
classify_row ~utc_offset_seconds cols ~row:(i + 2) fields)
378
data_rows
379
in
380
let groups, row_warnings = assemble outcomes in
381
let workouts_rev, workout_warnings_rev, _ =
382
List.fold_left
383
(fun (workouts, warnings, index) g ->
384
match build_workout ~batch_id ~index g with
385
| Ok w -> (w :: workouts, warnings, index + 1)
386
| Error warning -> (workouts, warning :: warnings, index + 1))
387
([], [], 1) groups
388
in
389
let workouts = List.rev workouts_rev in
390
(* Batch warnings stay in source order: the row warnings, then any
391
workout-level rejections in group order. *)
392
let warnings = row_warnings @ List.rev workout_warnings_rev in
393
Ok
394
(WI.make_batch ~id:batch_id ~source ~fingerprint ~imported_at
395
~workouts ~warnings))
396