swimparse 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -4
- package/cli.js +24 -11
- package/docs/qualifying-cuts.md +94 -0
- package/package.json +10 -4
- package/src/detect.js +13 -1
- package/src/index.js +39 -8
- package/src/model.js +90 -0
- package/src/setup.js +341 -0
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@ Reads swim-meet result files — **SDIF v3** (`.sd3`) and **Hy-Tek** (`.hy3`)
|
|
|
4
4
|
one `NormalizedMeet` JSON shape, so tools consume a single contract instead of
|
|
5
5
|
re-implementing fixed-width parsing twice.
|
|
6
6
|
|
|
7
|
+
It also reads the other end of a meet: **Hy-Tek meet-setup files** (`.ev3` / `.hyv`) —
|
|
8
|
+
the event list, session schedule, entry fees and qualifying cuts — as a
|
|
9
|
+
`NormalizedMeetSetup`.
|
|
10
|
+
|
|
7
11
|
- **Zero dependencies.** Plain ESM — browser, Node, and CI unchanged.
|
|
8
12
|
- **Lossless.** Every swim is kept: placing or not, exhibition, DQ, no-show — plus
|
|
9
13
|
birthdates, seed times, relay legs, splits and DQ reasons.
|
|
@@ -22,13 +26,13 @@ everything else.
|
|
|
22
26
|
|
|
23
27
|
| Concern | Where it belongs |
|
|
24
28
|
|---|---|
|
|
25
|
-
| SDIF / HY3 record layouts, times, dates, format detection | **swimparse** |
|
|
26
|
-
| The `NormalizedMeet`
|
|
29
|
+
| SDIF / HY3 / EV3 / HYV record layouts, times, dates, format detection | **swimparse** |
|
|
30
|
+
| The `NormalizedMeet` and `NormalizedMeetSetup` contracts | **swimparse** |
|
|
27
31
|
| Age bands, age-up date, age-group labels for swimmers | your league layer |
|
|
28
32
|
| Scoring: point values, which relays count, team totals | your league layer |
|
|
29
33
|
| Canonical team codes / alias mapping | your league layer |
|
|
30
34
|
| Stripping or aggregating PII | your league layer |
|
|
31
|
-
|
|
|
35
|
+
| Whether a swimmer meets a cut, course conversions, records, personal bests | your application |
|
|
32
36
|
|
|
33
37
|
## Install
|
|
34
38
|
|
|
@@ -50,12 +54,36 @@ const meet = parse(fileText, { filename: 'GG_at_WW.hy3' }); // auto-detects form
|
|
|
50
54
|
const meet = parse(fileText, { format: 'sdif-v3' }); // or force one
|
|
51
55
|
```
|
|
52
56
|
|
|
57
|
+
Meet-setup files go through `parseSetup` instead — they hold events, not results, so
|
|
58
|
+
they parse to a different shape rather than an empty `NormalizedMeet`:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import { parseSetup, qualifyingStandards } from 'swimparse';
|
|
62
|
+
|
|
63
|
+
const setup = parseSetup(fileText, { filename: 'Meet Events-2026 Champs.ev3' });
|
|
64
|
+
setup.events[0].qualifyingTimes; // { LCM, SCM, SCY } — the event's cut per course
|
|
65
|
+
qualifyingStandards(setup); // flat cut table: one row per event that has one
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`qualifyingTimes` is the file verbatim. `qualifyingStandards()` is the cut table you
|
|
69
|
+
would publish, and makes exactly one judgement the parse does not: a meet that does not
|
|
70
|
+
accept a course may fill that column with a placeholder (`0.01`, `1.00`) on every event
|
|
71
|
+
rather than leaving it blank, and those are dropped along with any row left with
|
|
72
|
+
nothing real.
|
|
73
|
+
|
|
74
|
+
If you are consuming the cuts, read
|
|
75
|
+
**[docs/qualifying-cuts.md](docs/qualifying-cuts.md)** first — the output shape, and the
|
|
76
|
+
four things that are easy to get wrong (course keys, what `null` means, comparing on
|
|
77
|
+
`seconds`, and where the meet's rules end and yours begin).
|
|
78
|
+
|
|
53
79
|
CLI:
|
|
54
80
|
|
|
55
81
|
```bash
|
|
56
82
|
swimparse meet.hy3 --pretty # NormalizedMeet JSON to stdout
|
|
57
83
|
swimparse meet.sd3 -o meet.json # to a file
|
|
58
84
|
swimparse a.sd3 b.hy3 -d out/ # one <name>.json per input
|
|
85
|
+
swimparse events.ev3 --pretty # NormalizedMeetSetup JSON
|
|
86
|
+
swimparse events.ev3 --cuts # just the qualifying-time table
|
|
59
87
|
```
|
|
60
88
|
|
|
61
89
|
## The NormalizedMeet contract
|
|
@@ -84,6 +112,40 @@ for the full typedefs. Highlights:
|
|
|
84
112
|
| Points | stored | absent (reads `0`) |
|
|
85
113
|
| Names | 28-char field | wider, less truncation |
|
|
86
114
|
|
|
115
|
+
## The NormalizedMeetSetup contract
|
|
116
|
+
|
|
117
|
+
`{ format, source, meet, sessions, events }` — see [`src/model.js`](src/model.js). A
|
|
118
|
+
setup file is the meet before anyone has entered it, so `events[]` here are event
|
|
119
|
+
*definitions*, not results.
|
|
120
|
+
|
|
121
|
+
- **`event.qualifyingTimes`** is `{ LCM, SCM, SCY }` — the same cut expressed in each
|
|
122
|
+
course, each a `SwimTime` or `null`. A meet that sets no cuts (most invitationals)
|
|
123
|
+
parses to all-null, and `qualifyingStandards()` returns `[]`. The two file flavours
|
|
124
|
+
store those three columns in different orders — the `.hyv` rotates them to start at
|
|
125
|
+
the meet's own course — so read them from here, keyed by course, rather than by
|
|
126
|
+
column position.
|
|
127
|
+
- **`event.course`** is the event's own course as stated in the file (`.ev3` only).
|
|
128
|
+
- **`meet.qualifyingSince`** is the start of the period a cut may be swum in (`.ev3`
|
|
129
|
+
only). Inferred from the files rather than from a spec — see `src/setup.js`.
|
|
130
|
+
- **`event.round`** is `finals` or `prelims`; `rounds` is 1 for timed finals, 2 for
|
|
131
|
+
prelims-plus-finals (`.ev3` only).
|
|
132
|
+
- **`sessions`** is the day/start-time schedule, collapsed out of the per-event stamps.
|
|
133
|
+
A session id may be alphanumeric (`"2G"` — session 2, girls). `.hyv` carries no
|
|
134
|
+
schedule, so it parses to `[]`.
|
|
135
|
+
- **Event numbers keep their age-group letter** (`"1A"`, `"1B"`, `"1C"`).
|
|
136
|
+
|
|
137
|
+
### ev3 vs hyv
|
|
138
|
+
|
|
139
|
+
Meet Manager exports both together in one zip and they describe the same events. The
|
|
140
|
+
`.ev3` is the richer file — sessions, day, event order, start times, relay legs,
|
|
141
|
+
sanction number, venue address, entry deadline. The `.hyv` is the Team Manager import
|
|
142
|
+
file: events, ages, fees and cuts only. swimparse parses both, and the test suite
|
|
143
|
+
asserts they agree event-for-event.
|
|
144
|
+
|
|
145
|
+
Note that SDIF also defines an `.ev3` meet-events file, which is fixed-width and a
|
|
146
|
+
different format. Detection sniffs content, so a fixed-width `.ev3` still routes to
|
|
147
|
+
the SDIF adapter.
|
|
148
|
+
|
|
87
149
|
## Privacy
|
|
88
150
|
|
|
89
151
|
The lossless rule means output carries **`swimmers[].birthDate` and `usasId` whenever the
|
|
@@ -97,10 +159,13 @@ needs exact ages). Putting that choice in the parser would force one answer on e
|
|
|
97
159
|
Test fixtures in this repo are synthetic — every identity is a public figure with a
|
|
98
160
|
shifted birth year; see [`test/fixtures/README.md`](test/fixtures/README.md).
|
|
99
161
|
|
|
162
|
+
**Meet-setup files are the exception**: they contain no swimmers at all, so a
|
|
163
|
+
`NormalizedMeetSetup` is safe to publish as-is.
|
|
164
|
+
|
|
100
165
|
## Tests
|
|
101
166
|
|
|
102
167
|
```bash
|
|
103
|
-
node --test # golden snapshots + SDIF↔HY3 cross-agreement
|
|
168
|
+
node --test # golden snapshots + SDIF↔HY3 and EV3↔HYV cross-agreement
|
|
104
169
|
```
|
|
105
170
|
|
|
106
171
|
## License
|
package/cli.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* swimparse CLI — turn .sd3/.hy3
|
|
3
|
+
* swimparse CLI — turn .sd3/.hy3 results, or .ev3/.hyv meet setups, into JSON.
|
|
4
4
|
*
|
|
5
5
|
* swimparse meet.hy3 # JSON to stdout
|
|
6
6
|
* swimparse meet.sd3 -o meet.json # JSON to a file
|
|
7
7
|
* swimparse a.sd3 b.hy3 -d out/ # one <name>.json per input, into out/
|
|
8
8
|
* swimparse meet.hy3 --pretty # 2-space indented
|
|
9
|
+
* swimparse events.ev3 # NormalizedMeetSetup JSON
|
|
10
|
+
* swimparse events.ev3 --cuts # just the qualifying-time table
|
|
9
11
|
*
|
|
10
12
|
* PRIVACY: the output contains swimmer birthdates and registration ids exactly
|
|
11
13
|
* as the source file carries them. For a youth meet that is PII for minors —
|
|
@@ -14,7 +16,7 @@
|
|
|
14
16
|
|
|
15
17
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
16
18
|
import { basename, join, extname } from 'node:path';
|
|
17
|
-
import { parse } from './src/index.js';
|
|
19
|
+
import { parse, parseSetup, detectFormat, qualifyingStandards } from './src/index.js';
|
|
18
20
|
|
|
19
21
|
function main(argv) {
|
|
20
22
|
const args = argv.slice(2);
|
|
@@ -22,12 +24,14 @@ function main(argv) {
|
|
|
22
24
|
let outFile = null;
|
|
23
25
|
let outDir = null;
|
|
24
26
|
let pretty = false;
|
|
27
|
+
let cuts = false;
|
|
25
28
|
|
|
26
29
|
for (let i = 0; i < args.length; i++) {
|
|
27
30
|
const a = args[i];
|
|
28
31
|
if (a === '-o' || a === '--out') outFile = args[++i];
|
|
29
32
|
else if (a === '-d' || a === '--out-dir') outDir = args[++i];
|
|
30
33
|
else if (a === '--pretty') pretty = true;
|
|
34
|
+
else if (a === '--cuts') cuts = true;
|
|
31
35
|
else if (a === '-h' || a === '--help') return help(0);
|
|
32
36
|
else if (a.startsWith('-')) return fail(`unknown option: ${a}`);
|
|
33
37
|
else inputs.push(a);
|
|
@@ -39,15 +43,21 @@ function main(argv) {
|
|
|
39
43
|
if (outDir) mkdirSync(outDir, { recursive: true });
|
|
40
44
|
|
|
41
45
|
for (const file of inputs) {
|
|
42
|
-
const
|
|
43
|
-
const
|
|
46
|
+
const content = readFileSync(file, 'latin1');
|
|
47
|
+
const format = detectFormat(content, file);
|
|
48
|
+
const isSetup = format === 'ev3' || format === 'hyv';
|
|
49
|
+
if (cuts && !isSetup) return fail(`--cuts needs a meet-setup file (.ev3/.hyv); ${file} is ${format || 'unrecognized'}`);
|
|
50
|
+
|
|
51
|
+
const meet = isSetup ? parseSetup(content, { filename: file }) : parse(content, { filename: file });
|
|
52
|
+
const json = JSON.stringify(cuts ? qualifyingStandards(meet) : meet, null, indent);
|
|
53
|
+
const summary = `${meet.format}, ${meet.events.length} events`;
|
|
44
54
|
if (outDir) {
|
|
45
55
|
const name = basename(file, extname(file)) + '.json';
|
|
46
56
|
writeFileSync(join(outDir, name), json);
|
|
47
|
-
process.stderr.write(`wrote ${join(outDir, name)} (${
|
|
57
|
+
process.stderr.write(`wrote ${join(outDir, name)} (${summary})\n`);
|
|
48
58
|
} else if (outFile) {
|
|
49
59
|
writeFileSync(outFile, json);
|
|
50
|
-
process.stderr.write(`wrote ${outFile} (${
|
|
60
|
+
process.stderr.write(`wrote ${outFile} (${summary})\n`);
|
|
51
61
|
} else {
|
|
52
62
|
process.stdout.write(json + '\n');
|
|
53
63
|
}
|
|
@@ -57,15 +67,18 @@ function main(argv) {
|
|
|
57
67
|
|
|
58
68
|
function help(codeNum) {
|
|
59
69
|
process.stdout.write(
|
|
60
|
-
'Usage: swimparse <file...> [-o out.json | -d out-dir] [--pretty]\n' +
|
|
61
|
-
' Parses SDIF (.sd3) or Hy-Tek (.hy3) results into NormalizedMeet JSON
|
|
70
|
+
'Usage: swimparse <file...> [-o out.json | -d out-dir] [--pretty] [--cuts]\n' +
|
|
71
|
+
' Parses SDIF (.sd3) or Hy-Tek (.hy3) results into NormalizedMeet JSON, and\n' +
|
|
72
|
+
' Hy-Tek meet-setup files (.ev3/.hyv) into NormalizedMeetSetup JSON.\n' +
|
|
62
73
|
' -o, --out <path> write a single input to this file\n' +
|
|
63
74
|
' -d, --out-dir <dir> write one <name>.json per input into this directory\n' +
|
|
64
75
|
' --pretty 2-space indented JSON\n' +
|
|
76
|
+
' --cuts setup files only: emit just the qualifying-time table\n' +
|
|
65
77
|
'\n' +
|
|
66
|
-
'
|
|
67
|
-
' before publishing.
|
|
68
|
-
'
|
|
78
|
+
' Result output carries swimmer birthdates as they appear in the file —\n' +
|
|
79
|
+
' sanitize before publishing. Setup files contain no personal data.\n' +
|
|
80
|
+
' Age banding, scoring, and team-code mapping are league policy and are\n' +
|
|
81
|
+
' not performed here.\n'
|
|
69
82
|
);
|
|
70
83
|
return codeNum;
|
|
71
84
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Qualifying cuts from a meet-setup file
|
|
2
|
+
|
|
3
|
+
What `qualifyingStandards()` gives you, what it deliberately does not, and the four
|
|
4
|
+
mistakes that are easy to make with it.
|
|
5
|
+
|
|
6
|
+
## The shape
|
|
7
|
+
|
|
8
|
+
One row per event that states a real cut. This is verbatim output, not an illustration:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"eventNumber": "55",
|
|
13
|
+
"description": "Girls 13-14 50m Freestyle",
|
|
14
|
+
"gender": "F",
|
|
15
|
+
"ageGroup": { "label": "13-14", "lower": 13, "upper": 14 },
|
|
16
|
+
"distance": 50,
|
|
17
|
+
"stroke": "Freestyle",
|
|
18
|
+
"LCM": { "text": "29.49", "seconds": 29.49 },
|
|
19
|
+
"SCM": { "text": "28.89", "seconds": 28.89 },
|
|
20
|
+
"SCY": { "text": "25.89", "seconds": 25.89 }
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`LCM` / `SCM` / `SCY` are the same standard stated three ways — one per course — each a
|
|
25
|
+
`SwimTime` (`{ text, seconds }`) or `null`. Every other field describes the event and
|
|
26
|
+
matches what the result adapters emit for the same event, so a cut row joins to a parsed
|
|
27
|
+
result on `description`, or on `eventNumber` within one meet.
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { parseSetup, qualifyingStandards } from 'swimparse';
|
|
31
|
+
|
|
32
|
+
const setup = parseSetup(readFileSync('Meet Events-2026 Champs.ev3', 'latin1'));
|
|
33
|
+
const cuts = qualifyingStandards(setup);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or from the CLI — `swimparse events.ev3 --cuts`, which is the same array. To publish it
|
|
37
|
+
as a table:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
swimparse events.ev3 --cuts \
|
|
41
|
+
| jq -r '["Event","LCM","SCM","SCY"], (.[] | [.description, .LCM.text, .SCM.text, .SCY.text]) | @csv'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Four things to get right
|
|
45
|
+
|
|
46
|
+
**1. Read by course key, never by column position.** The `.ev3` and `.hyv` of the same
|
|
47
|
+
meet order their three time columns differently — the `.hyv` rotates them to start at
|
|
48
|
+
the meet's own course. swimparse resolves that for you; downstream code that
|
|
49
|
+
re-flattens these rows back into a fixed 3-column layout must carry the course label
|
|
50
|
+
with each value or it will re-introduce the bug.
|
|
51
|
+
|
|
52
|
+
**2. `null` means the meet stated no cut in that course.** It does not mean "any time
|
|
53
|
+
qualifies", and it is not an invitation to convert a time from another course.
|
|
54
|
+
**swimparse never converts between courses** — a converted time is a rule the meet
|
|
55
|
+
either accepts or does not, and only the meet announcement says which. If a swimmer's
|
|
56
|
+
only time is SCY and the SCY cut is `null`, the honest answer to "do they qualify" is
|
|
57
|
+
*this file cannot tell you*.
|
|
58
|
+
|
|
59
|
+
A course can also be refused loudly rather than left blank: a meet may fill a column it
|
|
60
|
+
does not accept with a placeholder like `0.01`. `qualifyingStandards()` drops those, so
|
|
61
|
+
they arrive as `null` here — but `setup.events[].qualifyingTimes` keeps them verbatim,
|
|
62
|
+
which is where to look if you need to know the difference between "not stated" and
|
|
63
|
+
"stated as unusable".
|
|
64
|
+
|
|
65
|
+
**3. Compare on `seconds`, display `text`.** `seconds` is a float rounded to hundredths;
|
|
66
|
+
`text` is the canonical `M:SS.ss` form. Never string-compare times.
|
|
67
|
+
|
|
68
|
+
**4. Meeting a cut is your rule, not the file's.** swimparse reports the standard; it
|
|
69
|
+
has no opinion on whether a swimmer meets it. Whether an equal time qualifies, whether a
|
|
70
|
+
bonus or unqualified entry is allowed, how many events a swimmer may enter, and whether
|
|
71
|
+
the swim happened inside the eligible period are all meet rules that live in the meet
|
|
72
|
+
announcement. `meet.qualifyingSince` is the file's own start-of-period date when it
|
|
73
|
+
states one — it is inferred from sample files rather than from a published spec, so
|
|
74
|
+
treat it as a hint to check against the announcement, not as authority.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
// The comparison itself is one line; the judgement around it is yours.
|
|
78
|
+
const qualifies = (swimSeconds, cut) => cut != null && swimSeconds <= cut.seconds;
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## What is absent, and why
|
|
82
|
+
|
|
83
|
+
Relays usually carry no cut at all, so they usually do not appear in this table. Events
|
|
84
|
+
a meet deliberately leaves open — bonus or unqualified events — do not appear either,
|
|
85
|
+
and are indistinguishable here from events the meet forgot to configure. If you need
|
|
86
|
+
every event whether cut or not, iterate `setup.events` instead and read
|
|
87
|
+
`qualifyingTimes` yourself; `qualifyingStandards()` is the convenience view, not the
|
|
88
|
+
whole file.
|
|
89
|
+
|
|
90
|
+
## Stability
|
|
91
|
+
|
|
92
|
+
The field names above are the contract. Additions are possible; renames or a change of
|
|
93
|
+
meaning are not, without a major version. Two rules will not change:
|
|
94
|
+
`qualifyingStandards()` stays filtered and `events[].qualifyingTimes` stays verbatim.
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "swimparse",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Parses SDIF v3 (.sd3) and Hy-Tek (.hy3) swim-meet result files into one NormalizedMeet JSON contract. Zero dependencies; runs in the browser, Node, and CI.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Parses SDIF v3 (.sd3) and Hy-Tek (.hy3) swim-meet result files into one NormalizedMeet JSON contract, and Hy-Tek meet-setup files (.ev3/.hyv) — events, sessions and qualifying cuts — into a NormalizedMeetSetup. Zero dependencies; runs in the browser, Node, and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
|
9
9
|
"./sdif": "./src/sdif.js",
|
|
10
|
-
"./hy3": "./src/hy3.js"
|
|
10
|
+
"./hy3": "./src/hy3.js",
|
|
11
|
+
"./setup": "./src/setup.js"
|
|
11
12
|
},
|
|
12
13
|
"bin": {
|
|
13
14
|
"swimparse": "cli.js"
|
|
@@ -15,7 +16,8 @@
|
|
|
15
16
|
"files": [
|
|
16
17
|
"src/",
|
|
17
18
|
"cli.js",
|
|
18
|
-
"README.md"
|
|
19
|
+
"README.md",
|
|
20
|
+
"docs/"
|
|
19
21
|
],
|
|
20
22
|
"scripts": {
|
|
21
23
|
"test": "node --test"
|
|
@@ -31,7 +33,11 @@
|
|
|
31
33
|
"sdif",
|
|
32
34
|
"sd3",
|
|
33
35
|
"hy3",
|
|
36
|
+
"ev3",
|
|
37
|
+
"hyv",
|
|
34
38
|
"hy-tek",
|
|
39
|
+
"meet-events",
|
|
40
|
+
"time-standards",
|
|
35
41
|
"meet-results",
|
|
36
42
|
"parser",
|
|
37
43
|
"swim-meet"
|
package/src/detect.js
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
+
* `ev3` and `hyv` are meet SETUP files (events, no results) — parse those with
|
|
8
|
+
* parseSetup(), not parse().
|
|
9
|
+
*
|
|
7
10
|
* @param {string} content
|
|
8
11
|
* @param {string} [filename] optional, used only as a tie-breaker
|
|
9
|
-
* @returns {'sdif-v3'|'hy3'|null}
|
|
12
|
+
* @returns {'sdif-v3'|'hy3'|'ev3'|'hyv'|null}
|
|
10
13
|
*/
|
|
11
14
|
export function detectFormat(content, filename) {
|
|
12
15
|
const firstLines = String(content).split(/\r?\n/, 5);
|
|
@@ -20,10 +23,19 @@ export function detectFormat(content, filename) {
|
|
|
20
23
|
if (firstLines.some((l) => l.startsWith('B11') || l.startsWith('D0') || l.startsWith('D3'))) return 'sdif-v3';
|
|
21
24
|
if (firstLines.some((l) => l.startsWith('D1') || l.startsWith('E1'))) return 'hy3';
|
|
22
25
|
|
|
26
|
+
// Meet-setup exports are semicolon-delimited rather than fixed-width. The
|
|
27
|
+
// ev3 flavour terminates every record with `*>`; the hyv flavour does not.
|
|
28
|
+
// Checked after the record-code sniffs above so a fixed-width SDIF .ev3
|
|
29
|
+
// still routes to the SDIF adapter.
|
|
30
|
+
const delimited = firstLines.filter((l) => l.split(';').length >= 10);
|
|
31
|
+
if (delimited.length >= 2) return delimited.some((l) => l.trimEnd().endsWith('*>')) ? 'ev3' : 'hyv';
|
|
32
|
+
|
|
23
33
|
if (filename) {
|
|
24
34
|
const ext = filename.toLowerCase().split('.').pop();
|
|
25
35
|
if (ext === 'hy3') return 'hy3';
|
|
26
36
|
if (ext === 'sd3' || ext === 'cl2' || ext === 'txt') return 'sdif-v3';
|
|
37
|
+
if (ext === 'ev3') return 'ev3';
|
|
38
|
+
if (ext === 'hyv') return 'hyv';
|
|
27
39
|
}
|
|
28
40
|
return null;
|
|
29
41
|
}
|
package/src/index.js
CHANGED
|
@@ -1,32 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* swimparse — public entry point.
|
|
3
3
|
*
|
|
4
|
-
* Parse SDIF v3 (.sd3) or Hy-Tek (.hy3) meet results into one NormalizedMeet
|
|
4
|
+
* Parse SDIF v3 (.sd3) or Hy-Tek (.hy3) meet results into one NormalizedMeet,
|
|
5
|
+
* and Hy-Tek meet-setup files (.ev3/.hyv) into one NormalizedMeetSetup.
|
|
5
6
|
* Zero dependencies; runs in the browser, Node, and CI.
|
|
6
7
|
*
|
|
7
|
-
* import { parse, detectFormat } from 'swimparse';
|
|
8
|
-
* const meet
|
|
8
|
+
* import { parse, parseSetup, detectFormat } from 'swimparse';
|
|
9
|
+
* const meet = parse(fileText, { filename: 'GG_at_WW.hy3' });
|
|
10
|
+
* const setup = parseSetup(eventsText, { filename: 'MeetEvents.ev3' });
|
|
9
11
|
*
|
|
10
|
-
* SCOPE: this library reads meet
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* SCOPE: this library reads meet files and emits JSON. That is all it does. It
|
|
13
|
+
* has no concept of a league — no age bands, no scoring rules, no team registry,
|
|
14
|
+
* and no opinion about whether a given swimmer meets a cut. A setup file states
|
|
15
|
+
* its own qualifying times and those are read like any other field, but applying
|
|
16
|
+
* them is league policy and belongs to the application consuming this output.
|
|
14
17
|
*
|
|
15
|
-
* PRIVACY:
|
|
18
|
+
* PRIVACY: a parsed RESULT file is a lossless superset of its source, so it CARRIES
|
|
16
19
|
* SWIMMER BIRTHDATES (`swimmers[].birthDate`, individual `results[].birthDate`)
|
|
17
20
|
* and USA-S registration ids. For youth meets that is PII for minors — treat
|
|
18
21
|
* every parse result as confidential until your application has stripped or
|
|
19
22
|
* aggregated it. swimparse deliberately does not do that for you: what counts as
|
|
20
23
|
* safe is a league decision (a summer league publishes age-group labels; a
|
|
21
24
|
* USA-Swimming tool needs exact ages), so it belongs to the consumer.
|
|
25
|
+
*
|
|
26
|
+
* Meet-SETUP files (.ev3/.hyv) are the exception: they describe a meet nobody has
|
|
27
|
+
* entered yet, so a NormalizedMeetSetup contains no personal data at all.
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
import { parseSdif } from './sdif.js';
|
|
25
31
|
import { parseHy3 } from './hy3.js';
|
|
32
|
+
import { parseEv3, parseHyv } from './setup.js';
|
|
26
33
|
import { detectFormat } from './detect.js';
|
|
27
34
|
|
|
28
35
|
export { parseSdif } from './sdif.js';
|
|
29
36
|
export { parseHy3 } from './hy3.js';
|
|
37
|
+
export { parseEv3, parseHyv, qualifyingStandards } from './setup.js';
|
|
30
38
|
export { detectFormat } from './detect.js';
|
|
31
39
|
export * from './model.js';
|
|
32
40
|
export * from './constants.js';
|
|
@@ -45,5 +53,28 @@ export function parse(content, opts = {}) {
|
|
|
45
53
|
const format = opts.format || detectFormat(content, opts.filename);
|
|
46
54
|
if (format === 'sdif-v3') return parseSdif(content);
|
|
47
55
|
if (format === 'hy3') return parseHy3(content);
|
|
56
|
+
if (format === 'ev3' || format === 'hyv') {
|
|
57
|
+
throw new Error(`swimparse: this is a Hy-Tek meet SETUP file (.${format}) — it holds events, not results. Use parseSetup().`);
|
|
58
|
+
}
|
|
48
59
|
throw new Error('swimparse: could not detect meet-result format (expected SDIF .sd3 or Hy-Tek .hy3)');
|
|
49
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parses a Hy-Tek meet-setup file — the event list, sessions and qualifying
|
|
64
|
+
* cuts a meet is built from, before anyone has entered it.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} content
|
|
67
|
+
* @param {Object} [opts]
|
|
68
|
+
* @param {'ev3'|'hyv'} [opts.format] force a format, skipping detection
|
|
69
|
+
* @param {string} [opts.filename] used as a detection tie-breaker
|
|
70
|
+
* @returns {import('./model.js').NormalizedMeetSetup}
|
|
71
|
+
*/
|
|
72
|
+
export function parseSetup(content, opts = {}) {
|
|
73
|
+
const format = opts.format || detectFormat(content, opts.filename);
|
|
74
|
+
if (format === 'ev3') return parseEv3(content);
|
|
75
|
+
if (format === 'hyv') return parseHyv(content);
|
|
76
|
+
if (format === 'sdif-v3' || format === 'hy3') {
|
|
77
|
+
throw new Error(`swimparse: this is a meet RESULT file (${format}), not a setup file. Use parse().`);
|
|
78
|
+
}
|
|
79
|
+
throw new Error('swimparse: could not detect meet-setup format (expected Hy-Tek .ev3 or .hyv)');
|
|
80
|
+
}
|
package/src/model.js
CHANGED
|
@@ -84,6 +84,96 @@
|
|
|
84
84
|
* @property {(IndividualResult|RelayResult)[]} results
|
|
85
85
|
*/
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The NormalizedMeetSetup contract — what a meet-SETUP file (.ev3/.hyv) holds.
|
|
89
|
+
*
|
|
90
|
+
* A setup file is the meet before it has entrants: the event list, the session
|
|
91
|
+
* schedule, the entry fees, and the qualifying cuts. There are no swimmers and
|
|
92
|
+
* no results in it, so it is a different shape from NormalizedMeet rather than
|
|
93
|
+
* an empty one — `events[]` here are event *definitions*, not event results.
|
|
94
|
+
*
|
|
95
|
+
* It carries no personal data at all, which makes it the one swimparse output
|
|
96
|
+
* that is safe to publish as-is.
|
|
97
|
+
*
|
|
98
|
+
* @typedef {Object} NormalizedMeetSetup
|
|
99
|
+
* @property {'ev3'|'hyv'} format
|
|
100
|
+
* @property {Object} source Producing software (header record).
|
|
101
|
+
* @property {string} [source.software]
|
|
102
|
+
* @property {string} [source.version]
|
|
103
|
+
* @property {string|null} [source.createdAt] ISO date the file was exported.
|
|
104
|
+
* @property {SetupMeetInfo} meet
|
|
105
|
+
* @property {SetupSession[]} sessions Empty for hyv, which has no sessions.
|
|
106
|
+
* @property {SetupEvent[]} events
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @typedef {Object} SetupMeetInfo
|
|
111
|
+
* @property {string} name
|
|
112
|
+
* @property {string} rawName
|
|
113
|
+
* @property {string} [hostName] Host or facility as printed.
|
|
114
|
+
* @property {string|null} startDate ISO "YYYY-MM-DD".
|
|
115
|
+
* @property {string|null} endDate
|
|
116
|
+
* @property {string|null} [ageUpDate] The date ages are computed as of.
|
|
117
|
+
* @property {string|null} [course] 'SCY' | 'LCM' | 'SCM'.
|
|
118
|
+
* @property {string} [sanction] LSC sanction number (ev3 only).
|
|
119
|
+
* @property {string|null} [entryDeadline] ISO (ev3 only).
|
|
120
|
+
* @property {string|null} [qualifyingSince] Start of the period a cut may be
|
|
121
|
+
* swum in — INFERRED from the files,
|
|
122
|
+
* not from a spec (ev3 only).
|
|
123
|
+
* @property {Object} [location] Address fields (ev3 only).
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @typedef {Object} SetupSession
|
|
128
|
+
* @property {string} id As numbered in the file — may be
|
|
129
|
+
* alphanumeric ("1", "2G").
|
|
130
|
+
* @property {number|null} day 1-based day of the meet.
|
|
131
|
+
* @property {string|null} startTime 24-hour "HH:MM".
|
|
132
|
+
* @property {number} eventCount
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @typedef {Object} SetupEvent
|
|
137
|
+
* @property {string} number As printed, letter and all ("1A").
|
|
138
|
+
* @property {'individual'|'relay'} type
|
|
139
|
+
* @property {'finals'|'prelims'|null} round 'prelims' = prelims feeding a final.
|
|
140
|
+
* @property {number|null} rounds 1 = timed finals, 2 = prelims+finals
|
|
141
|
+
* (ev3 only).
|
|
142
|
+
* @property {'M'|'F'|'X'} gender
|
|
143
|
+
* @property {number|null} distance
|
|
144
|
+
* @property {string} stroke Canonical stroke name.
|
|
145
|
+
* @property {string|null} course The event's own course as stated in
|
|
146
|
+
* the file — 'SCY' | 'LCM' | 'SCM'.
|
|
147
|
+
* ev3 only; the hyv states none.
|
|
148
|
+
* @property {{label:string, lower:number, upper:number}} ageGroup
|
|
149
|
+
* @property {string} description Human label, same form the result
|
|
150
|
+
* adapters produce.
|
|
151
|
+
* @property {number|null} relayLegs Legs per relay (ev3 only).
|
|
152
|
+
* @property {number|null} entryFee As stored, in the meet's currency.
|
|
153
|
+
* @property {{LCM: SwimTime|null, SCM: SwimTime|null, SCY: SwimTime|null}} qualifyingTimes
|
|
154
|
+
* The event's cut in each course — the
|
|
155
|
+
* same standard, stated three ways. All
|
|
156
|
+
* null when the meet sets no cuts. Kept
|
|
157
|
+
* verbatim, including the placeholder a
|
|
158
|
+
* meet may fill an unaccepted course
|
|
159
|
+
* with; qualifyingStandards() drops
|
|
160
|
+
* those.
|
|
161
|
+
* @property {{id:string, day:number|null, order:number|null, startTime:string|null}|null} session
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @typedef {Object} QualifyingStandard
|
|
166
|
+
* @property {string} eventNumber
|
|
167
|
+
* @property {string} description
|
|
168
|
+
* @property {'M'|'F'|'X'} gender
|
|
169
|
+
* @property {{label:string, lower:number, upper:number}} ageGroup
|
|
170
|
+
* @property {number|null} distance
|
|
171
|
+
* @property {string} stroke
|
|
172
|
+
* @property {SwimTime|null} LCM
|
|
173
|
+
* @property {SwimTime|null} SCM
|
|
174
|
+
* @property {SwimTime|null} SCY
|
|
175
|
+
*/
|
|
176
|
+
|
|
87
177
|
/**
|
|
88
178
|
* @typedef {Object} IndividualResult
|
|
89
179
|
* @property {'individual'} kind
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hy-Tek meet-SETUP adapter (.ev3 / .hyv) → NormalizedMeetSetup.
|
|
3
|
+
*
|
|
4
|
+
* A setup file is the meet's event list before anyone has entered it: no
|
|
5
|
+
* swimmers, no entries, no results. Meet Manager exports the pair together in
|
|
6
|
+
* one zip — `.ev3` (the richer one: sessions, day, event order, start times)
|
|
7
|
+
* and `.hyv` (the Team Manager import file: the same events, fewer columns).
|
|
8
|
+
*
|
|
9
|
+
* Neither is fixed-width. Both are **semicolon-delimited**, CP-1252, CRLF: one
|
|
10
|
+
* header record, then one record per event. `.ev3` records carry a trailing
|
|
11
|
+
* `*>` terminator, and its header carries a trailing checksum.
|
|
12
|
+
*
|
|
13
|
+
* ev3 1;1A;F;1;I;G;0;8;400;E;0;;;N;12;;;;;;;1;1;1;05:00PM;Y;5;4;1;0*>
|
|
14
|
+
* hyv 1A;F;F;I;0;8;400;5;;;;12;;;;;;
|
|
15
|
+
*
|
|
16
|
+
* NOTE ON THE NAME `.ev3`: SDIF also defines a meet-events file with that
|
|
17
|
+
* extension, fixed-width like `.sd3`. That is a different format; detect.js
|
|
18
|
+
* sniffs content, so a fixed-width `.ev3` still routes to the SDIF adapter.
|
|
19
|
+
*
|
|
20
|
+
* The three qualifying-time columns are the SAME cut in the three courses,
|
|
21
|
+
* verified column-for-column against Virginia Swimming's published 2025-2028
|
|
22
|
+
* Age Group Championship QT table (LCM / SCM / SCY), whose rows the fixture
|
|
23
|
+
* meets reproduce exactly. The two files order those columns differently:
|
|
24
|
+
*
|
|
25
|
+
* ev3 col 16 = LCM, col 18 = SCM, col 20 = SCY — FIXED, whatever the meet's
|
|
26
|
+
* own course. Confirmed against an SCY meet and an LCM meet.
|
|
27
|
+
* hyv cols 9, 13, 15 — ROTATED to start at the meet's own course, then
|
|
28
|
+
* cycling Y → L → S. An SCY meet reads (SCY, LCM, SCM); an LCM meet
|
|
29
|
+
* reads (LCM, SCM, SCY). Both confirmed; the SCM rotation (SCM, SCY,
|
|
30
|
+
* LCM) follows the same cycle but has not been seen in a real file.
|
|
31
|
+
*
|
|
32
|
+
* A course column a meet does not accept can be BLANKET-FILLED with a
|
|
33
|
+
* placeholder rather than left empty: the Eastern Zone fixture carries 0.01 or
|
|
34
|
+
* 1.00 in its SCM column on all 108 events, relays included. Those are stated
|
|
35
|
+
* times, so `qualifyingTimes` keeps them verbatim — dropping file data is not
|
|
36
|
+
* this layer's call — but `qualifyingStandards()` filters them, since a table
|
|
37
|
+
* of cuts is useless with them in. See that function.
|
|
38
|
+
*
|
|
39
|
+
* Each qualifying time is preceded by an always-empty column (ev3 15/17/19,
|
|
40
|
+
* hyv 8/12/14/16), almost certainly the matching "no faster than" limit that
|
|
41
|
+
* Meet Manager pairs with every cut. Empty in every sample, so its meaning is
|
|
42
|
+
* unconfirmed and it is not emitted.
|
|
43
|
+
*
|
|
44
|
+
* Columns deliberately left unread because the samples could not pin them down:
|
|
45
|
+
* ev3 10, 11, 12, 13 (constant), and the per-session trio 26/27/28.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { HY3_STROKE, SDIF_STROKE, STROKE, GENDER_DISPLAY, COURSE, ageGroup } from './constants.js';
|
|
49
|
+
import { timeFromText, normalizeDate } from './times.js';
|
|
50
|
+
|
|
51
|
+
/** Event-sex code → canonical gender. ev3 uses G/B, hyv uses F/M. */
|
|
52
|
+
const EVENT_SEX = { G: 'F', B: 'M', F: 'F', M: 'M', X: 'X' };
|
|
53
|
+
|
|
54
|
+
/** Round code → canonical round. 'P' means prelims feeding a final. */
|
|
55
|
+
const ROUND = { F: 'finals', P: 'prelims' };
|
|
56
|
+
|
|
57
|
+
/** A relay's stroke code means the relay stroke, not the individual one. */
|
|
58
|
+
const RELAY_STROKE_EV3 = { A: STROKE.FREESTYLE, E: STROKE.MEDLEY };
|
|
59
|
+
const RELAY_STROKE_HYV = { 1: STROKE.FREESTYLE, 5: STROKE.MEDLEY };
|
|
60
|
+
|
|
61
|
+
const clean = (s) => (s == null ? '' : String(s).trim());
|
|
62
|
+
|
|
63
|
+
/** "MM/DD/YYYY" → ISO "YYYY-MM-DD". */
|
|
64
|
+
const isoDate = (raw) => normalizeDate(clean(raw).replace(/\//g, ''));
|
|
65
|
+
|
|
66
|
+
/** "05:00PM" → "17:00". Returns null for anything else. */
|
|
67
|
+
function clockTime(raw) {
|
|
68
|
+
const m = /^(\d{1,2}):(\d{2})\s*([AP])M$/i.exec(clean(raw));
|
|
69
|
+
if (!m) return null;
|
|
70
|
+
let hour = parseInt(m[1], 10) % 12;
|
|
71
|
+
if (m[3].toUpperCase() === 'P') hour += 12;
|
|
72
|
+
return `${String(hour).padStart(2, '0')}:${m[2]}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const int = (raw) => {
|
|
76
|
+
const v = parseInt(clean(raw), 10);
|
|
77
|
+
return Number.isNaN(v) ? null : v;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const num = (raw) => {
|
|
81
|
+
const v = parseFloat(clean(raw));
|
|
82
|
+
return Number.isNaN(v) ? null : v;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Meet Manager writes an unset date as the Unix epoch. */
|
|
86
|
+
const EPOCH_SENTINEL = /^01\/01\/1970$/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Below this, a "qualifying time" is a placeholder for a course the meet does
|
|
90
|
+
* not accept, not a standard: no swim of any distance is a second long, and the
|
|
91
|
+
* Eastern Zone fixture blanket-fills its SCM column with 0.01/1.00 on nearly
|
|
92
|
+
* every event, relays included.
|
|
93
|
+
*/
|
|
94
|
+
const PLACEHOLDER_CUT_SECONDS = 1;
|
|
95
|
+
|
|
96
|
+
/** Splits a record, dropping the ev3 `*>` terminator from the last field. */
|
|
97
|
+
const fields = (line) => line.replace(/\*>\s*$/, '').split(';');
|
|
98
|
+
|
|
99
|
+
const records = (content) =>
|
|
100
|
+
String(content)
|
|
101
|
+
.split(/\r?\n/)
|
|
102
|
+
.filter((l) => l.trim() !== '')
|
|
103
|
+
.map(fields);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Builds the shared event shape from already-decoded parts.
|
|
107
|
+
* `description` matches the result adapters', so an event parsed from a setup
|
|
108
|
+
* file and the same event parsed from a result file read identically.
|
|
109
|
+
*/
|
|
110
|
+
function buildEvent({ number, type, round, rounds, gender, distance, stroke, course, lower, upper, entryFee, qualifyingTimes, relayLegs, session }) {
|
|
111
|
+
const ag = ageGroup(lower, upper);
|
|
112
|
+
const agLabel = type === 'relay' && ag.label === 'Open' ? '' : ag.label;
|
|
113
|
+
const description = `${GENDER_DISPLAY[gender]} ${agLabel} ${distance}m ${stroke}${type === 'relay' ? ' Relay' : ''}`
|
|
114
|
+
.replace(/\s+/g, ' ')
|
|
115
|
+
.trim();
|
|
116
|
+
return {
|
|
117
|
+
number,
|
|
118
|
+
type,
|
|
119
|
+
round,
|
|
120
|
+
rounds,
|
|
121
|
+
gender,
|
|
122
|
+
distance,
|
|
123
|
+
stroke,
|
|
124
|
+
course,
|
|
125
|
+
ageGroup: ag,
|
|
126
|
+
description,
|
|
127
|
+
relayLegs,
|
|
128
|
+
entryFee,
|
|
129
|
+
qualifyingTimes,
|
|
130
|
+
session,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** { LCM, SCM, SCY } from three raw time strings, any of which may be blank. */
|
|
135
|
+
const qualTimes = (lcm, scm, scy) => ({
|
|
136
|
+
LCM: timeFromText(lcm),
|
|
137
|
+
SCM: timeFromText(scm),
|
|
138
|
+
SCY: timeFromText(scy),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** The cycle the hyv rotates its qualifying-time columns through. */
|
|
142
|
+
const COURSE_CYCLE = ['Y', 'L', 'S'];
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Reads the hyv's three qualifying-time columns, which start at the meet's own
|
|
146
|
+
* course and cycle from there — see the header note.
|
|
147
|
+
* @param {string} courseCode single-char meet course from the hyv header
|
|
148
|
+
* @param {string[]} f the record's fields
|
|
149
|
+
*/
|
|
150
|
+
function hyvQualTimes(courseCode, f) {
|
|
151
|
+
const start = Math.max(0, COURSE_CYCLE.indexOf(courseCode));
|
|
152
|
+
const times = { LCM: null, SCM: null, SCY: null };
|
|
153
|
+
[9, 13, 15].forEach((col, i) => {
|
|
154
|
+
times[COURSE[COURSE_CYCLE[(start + i) % COURSE_CYCLE.length]]] = timeFromText(f[col]);
|
|
155
|
+
});
|
|
156
|
+
return times;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Collapses the per-event session stamps into the meet's session list. */
|
|
160
|
+
function deriveSessions(events) {
|
|
161
|
+
const byId = new Map();
|
|
162
|
+
for (const ev of events) {
|
|
163
|
+
const s = ev.session;
|
|
164
|
+
if (!s) continue;
|
|
165
|
+
if (!byId.has(s.id)) byId.set(s.id, { id: s.id, day: s.day, startTime: s.startTime, eventCount: 0 });
|
|
166
|
+
byId.get(s.id).eventCount += 1;
|
|
167
|
+
}
|
|
168
|
+
return [...byId.values()];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Flattens a setup's qualifying cuts into one row per event that has one.
|
|
173
|
+
*
|
|
174
|
+
* The cuts are already on `setup.events[].qualifyingTimes`; this is the shape
|
|
175
|
+
* you want when the cuts *are* the thing you came for — a standards table to
|
|
176
|
+
* publish, diff against last season's, or check entries against. Events with no
|
|
177
|
+
* cut configured (the relays, in most meets) are left out.
|
|
178
|
+
*
|
|
179
|
+
* THE ONE PLACE THIS LAYER JUDGES THE DATA: a placeholder time in a course the
|
|
180
|
+
* meet does not accept is dropped here (see PLACEHOLDER_CUT_SECONDS), and a row
|
|
181
|
+
* left with nothing real goes with it. `event.qualifyingTimes` still carries
|
|
182
|
+
* every value the file stated — read that instead if you want the file verbatim.
|
|
183
|
+
*
|
|
184
|
+
* Beyond that, reading the file is all that happens: no conversion between
|
|
185
|
+
* courses, no "does this swimmer qualify" — that is the consumer's call.
|
|
186
|
+
*
|
|
187
|
+
* @param {NormalizedMeetSetup} setup
|
|
188
|
+
* @returns {QualifyingStandard[]}
|
|
189
|
+
*/
|
|
190
|
+
export function qualifyingStandards(setup) {
|
|
191
|
+
const real = (t) => (t && t.seconds > PLACEHOLDER_CUT_SECONDS ? t : null);
|
|
192
|
+
return (setup.events || [])
|
|
193
|
+
.map((ev) => ({
|
|
194
|
+
eventNumber: ev.number,
|
|
195
|
+
description: ev.description,
|
|
196
|
+
gender: ev.gender,
|
|
197
|
+
ageGroup: ev.ageGroup,
|
|
198
|
+
distance: ev.distance,
|
|
199
|
+
stroke: ev.stroke,
|
|
200
|
+
LCM: real(ev.qualifyingTimes && ev.qualifyingTimes.LCM),
|
|
201
|
+
SCM: real(ev.qualifyingTimes && ev.qualifyingTimes.SCM),
|
|
202
|
+
SCY: real(ev.qualifyingTimes && ev.qualifyingTimes.SCY),
|
|
203
|
+
}))
|
|
204
|
+
.filter((row) => row.LCM || row.SCM || row.SCY);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parses a Meet Manager `.ev3` meet-events export.
|
|
209
|
+
* @param {string} content
|
|
210
|
+
* @returns {import('./model.js').NormalizedMeetSetup}
|
|
211
|
+
*/
|
|
212
|
+
export function parseEv3(content) {
|
|
213
|
+
const [head, ...rows] = records(content);
|
|
214
|
+
if (!head) throw new Error('swimparse: empty .ev3 file');
|
|
215
|
+
|
|
216
|
+
const name = clean(head[0]);
|
|
217
|
+
const meet = {
|
|
218
|
+
name,
|
|
219
|
+
rawName: name,
|
|
220
|
+
hostName: clean(head[1]) || undefined,
|
|
221
|
+
startDate: isoDate(head[2]),
|
|
222
|
+
endDate: isoDate(head[3]),
|
|
223
|
+
ageUpDate: isoDate(head[4]),
|
|
224
|
+
course: COURSE[clean(head[5]).charAt(0)] || null,
|
|
225
|
+
sanction: clean(head[14]) || undefined,
|
|
226
|
+
entryDeadline: isoDate(head[23]),
|
|
227
|
+
// INFERRED, not confirmed against a spec: header field 16 reads
|
|
228
|
+
// 11/01/2024 in both Virginia championships (whose standards are the
|
|
229
|
+
// published 2025-2028 set), 08/06/2025 in the Eastern Zone meet (just
|
|
230
|
+
// after the 2025 zone championships), and the epoch sentinel in the one
|
|
231
|
+
// meet that sets no cuts at all. That is what a qualifying-period start
|
|
232
|
+
// looks like. Treated as unset when it reads as the epoch.
|
|
233
|
+
qualifyingSince: EPOCH_SENTINEL.test(clean(head[16])) ? null : isoDate(head[16]),
|
|
234
|
+
location: {
|
|
235
|
+
address: clean(head[24]) || undefined,
|
|
236
|
+
city: clean(head[26]) || undefined,
|
|
237
|
+
state: clean(head[27]) || undefined,
|
|
238
|
+
postalCode: clean(head[28]) || undefined,
|
|
239
|
+
country: clean(head[29]) || undefined,
|
|
240
|
+
lsc: clean(head[30]) || undefined,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const events = rows.map((f) => {
|
|
245
|
+
const type = clean(f[4]) === 'R' ? 'relay' : 'individual';
|
|
246
|
+
const strokeCode = clean(f[9]);
|
|
247
|
+
const stroke = (type === 'relay' && RELAY_STROKE_EV3[strokeCode]) || HY3_STROKE[strokeCode] || `Stroke ${strokeCode}`;
|
|
248
|
+
return buildEvent({
|
|
249
|
+
number: clean(f[1]) || clean(f[0]),
|
|
250
|
+
type,
|
|
251
|
+
round: ROUND[clean(f[2])] || null,
|
|
252
|
+
rounds: int(f[3]),
|
|
253
|
+
gender: EVENT_SEX[clean(f[5])] || 'X',
|
|
254
|
+
distance: int(f[8]),
|
|
255
|
+
stroke,
|
|
256
|
+
lower: clean(f[6]),
|
|
257
|
+
upper: clean(f[7]),
|
|
258
|
+
course: COURSE[clean(f[25])] || null,
|
|
259
|
+
entryFee: num(f[14]),
|
|
260
|
+
qualifyingTimes: qualTimes(f[16], f[18], f[20]),
|
|
261
|
+
relayLegs: type === 'relay' ? int(f[29]) : null,
|
|
262
|
+
session: {
|
|
263
|
+
id: clean(f[21]),
|
|
264
|
+
day: int(f[23]),
|
|
265
|
+
order: int(f[22]),
|
|
266
|
+
startTime: clockTime(f[24]),
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
format: 'ev3',
|
|
273
|
+
source: {
|
|
274
|
+
software: clean(head[9]) || undefined,
|
|
275
|
+
version: clean(head[11]) || undefined,
|
|
276
|
+
createdAt: isoDate(head[12]),
|
|
277
|
+
},
|
|
278
|
+
meet,
|
|
279
|
+
sessions: deriveSessions(events),
|
|
280
|
+
events,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Parses a Meet Manager `.hyv` meet-events export (the Team Manager import
|
|
286
|
+
* file). Same events as the `.ev3`, without sessions, days or start times.
|
|
287
|
+
* @param {string} content
|
|
288
|
+
* @returns {import('./model.js').NormalizedMeetSetup}
|
|
289
|
+
*/
|
|
290
|
+
export function parseHyv(content) {
|
|
291
|
+
const [head, ...rows] = records(content);
|
|
292
|
+
if (!head) throw new Error('swimparse: empty .hyv file');
|
|
293
|
+
|
|
294
|
+
const name = clean(head[0]);
|
|
295
|
+
const courseCode = clean(head[4]).charAt(0);
|
|
296
|
+
const meet = {
|
|
297
|
+
name,
|
|
298
|
+
rawName: name,
|
|
299
|
+
hostName: clean(head[5]) || undefined,
|
|
300
|
+
startDate: isoDate(head[1]),
|
|
301
|
+
endDate: isoDate(head[2]),
|
|
302
|
+
ageUpDate: isoDate(head[3]),
|
|
303
|
+
course: COURSE[courseCode] || null,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const events = rows.map((f) => {
|
|
307
|
+
const type = clean(f[3]) === 'R' ? 'relay' : 'individual';
|
|
308
|
+
const strokeCode = clean(f[7]);
|
|
309
|
+
const stroke = (type === 'relay' && RELAY_STROKE_HYV[strokeCode]) || SDIF_STROKE[strokeCode] || `Stroke ${strokeCode}`;
|
|
310
|
+
// hyv writes an open-ended upper age as 0; ev3 writes 109.
|
|
311
|
+
const upper = clean(f[5]) === '0' ? '109' : clean(f[5]);
|
|
312
|
+
return buildEvent({
|
|
313
|
+
number: clean(f[0]),
|
|
314
|
+
type,
|
|
315
|
+
round: ROUND[clean(f[1])] || null,
|
|
316
|
+
rounds: null,
|
|
317
|
+
gender: EVENT_SEX[clean(f[2])] || 'X',
|
|
318
|
+
distance: int(f[6]),
|
|
319
|
+
stroke,
|
|
320
|
+
lower: clean(f[4]),
|
|
321
|
+
upper,
|
|
322
|
+
course: null, // the hyv states no per-event course
|
|
323
|
+
entryFee: num(f[11]),
|
|
324
|
+
qualifyingTimes: hyvQualTimes(courseCode, f),
|
|
325
|
+
relayLegs: null,
|
|
326
|
+
session: null,
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
format: 'hyv',
|
|
332
|
+
source: {
|
|
333
|
+
software: clean(head[7]) || undefined,
|
|
334
|
+
version: clean(head[8]) || undefined,
|
|
335
|
+
createdAt: null,
|
|
336
|
+
},
|
|
337
|
+
meet,
|
|
338
|
+
sessions: [],
|
|
339
|
+
events,
|
|
340
|
+
};
|
|
341
|
+
}
|