swimparse 0.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.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/cli.js +81 -0
- package/package.json +39 -0
- package/src/constants.js +87 -0
- package/src/detect.js +29 -0
- package/src/hy3.js +242 -0
- package/src/index.js +49 -0
- package/src/model.js +211 -0
- package/src/sdif.js +188 -0
- package/src/times.js +99 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Goergen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# swimparse
|
|
2
|
+
|
|
3
|
+
Reads swim-meet result files — **SDIF v3** (`.sd3`) and **Hy-Tek** (`.hy3`) — and emits
|
|
4
|
+
one `NormalizedMeet` JSON shape, so tools consume a single contract instead of
|
|
5
|
+
re-implementing fixed-width parsing twice.
|
|
6
|
+
|
|
7
|
+
- **Zero dependencies.** Plain ESM — browser, Node, and CI unchanged.
|
|
8
|
+
- **Lossless.** Every swim is kept: placing or not, exhibition, DQ, no-show — plus
|
|
9
|
+
birthdates, seed times, relay legs, splits and DQ reasons.
|
|
10
|
+
- **Format-agnostic output.** SDIF and HY3 of the same meet parse to the same result.
|
|
11
|
+
|
|
12
|
+
## Scope
|
|
13
|
+
|
|
14
|
+
swimparse reads files and emits JSON. **That is all it does.** It has no concept of a
|
|
15
|
+
league: no age bands, no scoring rules, no team-code registry, no qualifying standards,
|
|
16
|
+
no synthesized meet names.
|
|
17
|
+
|
|
18
|
+
That boundary is deliberate. Those things differ per league and change over time, while
|
|
19
|
+
the file formats do not. Keeping them out means a summer-league scorer, a USA-Swimming
|
|
20
|
+
analyzer, and a championship-meet tool can share one parser and disagree about
|
|
21
|
+
everything else.
|
|
22
|
+
|
|
23
|
+
| Concern | Where it belongs |
|
|
24
|
+
|---|---|
|
|
25
|
+
| SDIF / HY3 record layouts, times, dates, format detection | **swimparse** |
|
|
26
|
+
| The `NormalizedMeet` contract | **swimparse** |
|
|
27
|
+
| Age bands, age-up date, age-group labels for swimmers | your league layer |
|
|
28
|
+
| Scoring: point values, which relays count, team totals | your league layer |
|
|
29
|
+
| Canonical team codes / alias mapping | your league layer |
|
|
30
|
+
| Stripping or aggregating PII | your league layer |
|
|
31
|
+
| Qualifying standards, records, personal bests | your application |
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
Consumed as a pinned git dependency:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
"dependencies": { "swimparse": "github:g0rgonus/swimparse#v0.1.0" }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Pin to a tag or SHA for reproducibility. `npm ci` clones it in CI with no publish step.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
import { parse, detectFormat } from 'swimparse';
|
|
47
|
+
|
|
48
|
+
const meet = parse(fileText, { filename: 'GG_at_WW.hy3' }); // auto-detects format
|
|
49
|
+
const meet = parse(fileText, { format: 'sdif-v3' }); // or force one
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
CLI:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
swimparse meet.hy3 --pretty # NormalizedMeet JSON to stdout
|
|
56
|
+
swimparse meet.sd3 -o meet.json # to a file
|
|
57
|
+
swimparse a.sd3 b.hy3 -d out/ # one <name>.json per input
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## The NormalizedMeet contract
|
|
61
|
+
|
|
62
|
+
`{ format, source, meet, teams, swimmers, events }` — see [`src/model.js`](src/model.js)
|
|
63
|
+
for the full typedefs. Highlights:
|
|
64
|
+
|
|
65
|
+
- **Times** always carry both `{ text: "1:11.35", seconds: 71.35 }`.
|
|
66
|
+
- **Dates** are ISO `YYYY-MM-DD`.
|
|
67
|
+
- **`result.status`** is `ok | dq | ns | dnf | scratch | exhibition`.
|
|
68
|
+
- **`event.ageGroup`** is the *event's* age range as printed in the file (`"9-10"`,
|
|
69
|
+
`"10 & Under"`, `"Open"`). It is **not** a swimmer's age group — computing that needs
|
|
70
|
+
a birthdate and a league's bands, which is your layer's job.
|
|
71
|
+
- **`team.code`** has the two-letter LSC prefix stripped (`VAWW` → `WW`), an SDIF file
|
|
72
|
+
convention; `team.fullCode` keeps the raw value. Mapping either onto a league's
|
|
73
|
+
canonical code is your layer's job.
|
|
74
|
+
- **`result.points`** is whatever the file stored. SDIF carries points; HY3 does not, so
|
|
75
|
+
it reads `0`. Deriving points from place is scoring, so it lives in your layer.
|
|
76
|
+
|
|
77
|
+
### Format differences worth knowing
|
|
78
|
+
|
|
79
|
+
| | SDIF (`.sd3`) | Hy-Tek (`.hy3`) |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| DQ time | nulled | **retained** (`finalTime` kept) |
|
|
82
|
+
| DQ reason | — | **`dqReason`** (e.g. "Arms: Underwater recovery") |
|
|
83
|
+
| Points | stored | absent (reads `0`) |
|
|
84
|
+
| Names | 28-char field | wider, less truncation |
|
|
85
|
+
|
|
86
|
+
## Privacy
|
|
87
|
+
|
|
88
|
+
The lossless rule means output carries **`swimmers[].birthDate` and `usasId` whenever the
|
|
89
|
+
file does.** At a youth meet that is PII for minors, so **treat every parse result as
|
|
90
|
+
confidential** until your application has stripped or aggregated it.
|
|
91
|
+
|
|
92
|
+
swimparse does not sanitize for you, on purpose: what counts as safe is a league decision
|
|
93
|
+
(a summer league publishes age-group labels and drops birthdates; a USA-Swimming tool
|
|
94
|
+
needs exact ages). Putting that choice in the parser would force one answer on everyone.
|
|
95
|
+
|
|
96
|
+
Test fixtures in this repo are synthetic — every identity is a public figure with a
|
|
97
|
+
shifted birth year; see [`test/fixtures/README.md`](test/fixtures/README.md).
|
|
98
|
+
|
|
99
|
+
## Tests
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
node --test # golden snapshots + SDIF↔HY3 cross-agreement
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
package/cli.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* swimparse CLI — turn .sd3/.hy3 files into NormalizedMeet JSON.
|
|
4
|
+
*
|
|
5
|
+
* swimparse meet.hy3 # JSON to stdout
|
|
6
|
+
* swimparse meet.sd3 -o meet.json # JSON to a file
|
|
7
|
+
* swimparse a.sd3 b.hy3 -d out/ # one <name>.json per input, into out/
|
|
8
|
+
* swimparse meet.hy3 --pretty # 2-space indented
|
|
9
|
+
*
|
|
10
|
+
* PRIVACY: the output contains swimmer birthdates and registration ids exactly
|
|
11
|
+
* as the source file carries them. For a youth meet that is PII for minors —
|
|
12
|
+
* do not publish it or commit it to a public repo without sanitizing first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { basename, join, extname } from 'node:path';
|
|
17
|
+
import { parse } from './src/index.js';
|
|
18
|
+
|
|
19
|
+
function main(argv) {
|
|
20
|
+
const args = argv.slice(2);
|
|
21
|
+
const inputs = [];
|
|
22
|
+
let outFile = null;
|
|
23
|
+
let outDir = null;
|
|
24
|
+
let pretty = false;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < args.length; i++) {
|
|
27
|
+
const a = args[i];
|
|
28
|
+
if (a === '-o' || a === '--out') outFile = args[++i];
|
|
29
|
+
else if (a === '-d' || a === '--out-dir') outDir = args[++i];
|
|
30
|
+
else if (a === '--pretty') pretty = true;
|
|
31
|
+
else if (a === '-h' || a === '--help') return help(0);
|
|
32
|
+
else if (a.startsWith('-')) return fail(`unknown option: ${a}`);
|
|
33
|
+
else inputs.push(a);
|
|
34
|
+
}
|
|
35
|
+
if (inputs.length === 0) return help(1);
|
|
36
|
+
if (outFile && inputs.length > 1) return fail('-o takes a single input; use -d for multiple');
|
|
37
|
+
|
|
38
|
+
const indent = pretty ? 2 : 0;
|
|
39
|
+
if (outDir) mkdirSync(outDir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
for (const file of inputs) {
|
|
42
|
+
const meet = parse(readFileSync(file, 'latin1'), { filename: file });
|
|
43
|
+
const json = JSON.stringify(meet, null, indent);
|
|
44
|
+
if (outDir) {
|
|
45
|
+
const name = basename(file, extname(file)) + '.json';
|
|
46
|
+
writeFileSync(join(outDir, name), json);
|
|
47
|
+
process.stderr.write(`wrote ${join(outDir, name)} (${meet.format}, ${meet.events.length} events)\n`);
|
|
48
|
+
} else if (outFile) {
|
|
49
|
+
writeFileSync(outFile, json);
|
|
50
|
+
process.stderr.write(`wrote ${outFile} (${meet.format}, ${meet.events.length} events)\n`);
|
|
51
|
+
} else {
|
|
52
|
+
process.stdout.write(json + '\n');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function help(codeNum) {
|
|
59
|
+
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.\n' +
|
|
62
|
+
' -o, --out <path> write a single input to this file\n' +
|
|
63
|
+
' -d, --out-dir <dir> write one <name>.json per input into this directory\n' +
|
|
64
|
+
' --pretty 2-space indented JSON\n' +
|
|
65
|
+
'\n' +
|
|
66
|
+
' Output carries swimmer birthdates as they appear in the file. Sanitize\n' +
|
|
67
|
+
' before publishing. Age banding, scoring, and team-code mapping are league\n' +
|
|
68
|
+
' policy and are not performed here.\n'
|
|
69
|
+
);
|
|
70
|
+
return codeNum;
|
|
71
|
+
}
|
|
72
|
+
function fail(msg) {
|
|
73
|
+
process.stderr.write(`swimparse: ${msg}\n`);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Set exitCode rather than process.exit(): process.exit() can terminate before
|
|
78
|
+
// a large stdout write drains to a pipe, truncating JSON at ~64KB (fine to a TTY
|
|
79
|
+
// or file, broken when a parent process captures stdout). Letting the event loop
|
|
80
|
+
// empty naturally flushes the write first.
|
|
81
|
+
process.exitCode = main(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "swimparse",
|
|
3
|
+
"version": "0.1.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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./sdif": "./src/sdif.js",
|
|
10
|
+
"./hy3": "./src/hy3.js"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"swimparse": "cli.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src/",
|
|
17
|
+
"cli.js",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/g0rgonus/swimparse.git"
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"keywords": [
|
|
30
|
+
"swimming",
|
|
31
|
+
"sdif",
|
|
32
|
+
"sd3",
|
|
33
|
+
"hy3",
|
|
34
|
+
"hy-tek",
|
|
35
|
+
"meet-results",
|
|
36
|
+
"parser",
|
|
37
|
+
"swim-meet"
|
|
38
|
+
]
|
|
39
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vocabulary for meet-result parsing.
|
|
3
|
+
*
|
|
4
|
+
* Both the SDIF and HY3 adapters map their raw codes onto these canonical
|
|
5
|
+
* values so that a NormalizedMeet looks identical regardless of source format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Canonical stroke names. */
|
|
9
|
+
export const STROKE = {
|
|
10
|
+
FREESTYLE: 'Freestyle',
|
|
11
|
+
BACKSTROKE: 'Backstroke',
|
|
12
|
+
BREASTSTROKE: 'Breaststroke',
|
|
13
|
+
BUTTERFLY: 'Butterfly',
|
|
14
|
+
IM: 'IM',
|
|
15
|
+
MEDLEY: 'Medley', // medley relay
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** SDIF numeric stroke codes → canonical stroke. */
|
|
19
|
+
export const SDIF_STROKE = {
|
|
20
|
+
'1': STROKE.FREESTYLE,
|
|
21
|
+
'2': STROKE.BACKSTROKE,
|
|
22
|
+
'3': STROKE.BREASTSTROKE,
|
|
23
|
+
'4': STROKE.BUTTERFLY,
|
|
24
|
+
'5': STROKE.IM,
|
|
25
|
+
'6': STROKE.FREESTYLE, // free relay
|
|
26
|
+
'7': STROKE.MEDLEY, // medley relay
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** HY3 letter stroke codes → canonical stroke. */
|
|
30
|
+
export const HY3_STROKE = {
|
|
31
|
+
A: STROKE.FREESTYLE,
|
|
32
|
+
B: STROKE.BACKSTROKE,
|
|
33
|
+
C: STROKE.BREASTSTROKE,
|
|
34
|
+
D: STROKE.BUTTERFLY,
|
|
35
|
+
E: STROKE.IM,
|
|
36
|
+
// F/G are diving events
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Raw single-char sex/gender code → canonical. */
|
|
40
|
+
export const GENDER = { M: 'M', F: 'F', X: 'X' };
|
|
41
|
+
|
|
42
|
+
/** Human display for a gender code, in event context. */
|
|
43
|
+
export const GENDER_DISPLAY = { M: 'Boys', F: 'Girls', X: 'Mixed' };
|
|
44
|
+
|
|
45
|
+
/** Course codes → canonical course. */
|
|
46
|
+
export const COURSE = { Y: 'SCY', L: 'LCM', S: 'SCM' };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Result status. A swim that "counts" is `ok`; everything else is excluded
|
|
50
|
+
* from scoring, but all of them are recorded — consumers filter.
|
|
51
|
+
* @typedef {'ok'|'dq'|'ns'|'dnf'|'scratch'|'exhibition'} ResultStatus
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parses a 4-char SDIF/HY3-style age code into a labelled age group.
|
|
56
|
+
* Examples: "0910" → 9-10, "UN10" → 10 & Under, "UNOV" → Open.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} lowerStr - 2-char lower bound ("09", "UN", "15", ...)
|
|
59
|
+
* @param {string} upperStr - 2-char upper bound ("10", "OV", "18", ...)
|
|
60
|
+
* @returns {{ label: string, lower: number, upper: number }}
|
|
61
|
+
*/
|
|
62
|
+
export function ageGroup(lowerStr, upperStr) {
|
|
63
|
+
const lo = (lowerStr || '').trim();
|
|
64
|
+
const hi = (upperStr || '').trim();
|
|
65
|
+
|
|
66
|
+
if ((lo === 'UN' || lo === '0' || lo === '') && (hi === 'OV' || hi === '109' || hi === '')) {
|
|
67
|
+
return { label: 'Open', lower: 0, upper: 99 };
|
|
68
|
+
}
|
|
69
|
+
if (lo === 'UN' || lo === '0' || lo === '') {
|
|
70
|
+
const upper = parseInt(hi, 10);
|
|
71
|
+
return Number.isNaN(upper)
|
|
72
|
+
? { label: 'Open', lower: 0, upper: 99 }
|
|
73
|
+
: { label: `${upper} & Under`, lower: 0, upper };
|
|
74
|
+
}
|
|
75
|
+
if (hi === 'OV' || hi === '109') {
|
|
76
|
+
const lower = parseInt(lo, 10);
|
|
77
|
+
return Number.isNaN(lower)
|
|
78
|
+
? { label: 'Open', lower: 0, upper: 99 }
|
|
79
|
+
: { label: `${lower} & Over`, lower, upper: 99 };
|
|
80
|
+
}
|
|
81
|
+
const lower = parseInt(lo, 10);
|
|
82
|
+
const upper = parseInt(hi, 10);
|
|
83
|
+
if (Number.isNaN(lower) || Number.isNaN(upper)) {
|
|
84
|
+
return { label: 'Open', lower: 0, upper: 99 };
|
|
85
|
+
}
|
|
86
|
+
return { label: lower === upper ? `${lower}` : `${lower}-${upper}`, lower, upper };
|
|
87
|
+
}
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format detection. Prefers content sniffing over file extension, since both
|
|
3
|
+
* formats are plain text and extensions are sometimes wrong.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} content
|
|
8
|
+
* @param {string} [filename] optional, used only as a tie-breaker
|
|
9
|
+
* @returns {'sdif-v3'|'hy3'|null}
|
|
10
|
+
*/
|
|
11
|
+
export function detectFormat(content, filename) {
|
|
12
|
+
const firstLines = String(content).split(/\r?\n/, 5);
|
|
13
|
+
for (const line of firstLines) {
|
|
14
|
+
const code = line.slice(0, 2);
|
|
15
|
+
// HY3 files open with an A1 file-description record.
|
|
16
|
+
if (code === 'A1') return 'hy3';
|
|
17
|
+
// SDIF v3 files open with an A0 file record (or at least carry B1).
|
|
18
|
+
if (code === 'A0' || code === 'B1') return 'sdif-v3';
|
|
19
|
+
}
|
|
20
|
+
if (firstLines.some((l) => l.startsWith('B11') || l.startsWith('D0') || l.startsWith('D3'))) return 'sdif-v3';
|
|
21
|
+
if (firstLines.some((l) => l.startsWith('D1') || l.startsWith('E1'))) return 'hy3';
|
|
22
|
+
|
|
23
|
+
if (filename) {
|
|
24
|
+
const ext = filename.toLowerCase().split('.').pop();
|
|
25
|
+
if (ext === 'hy3') return 'hy3';
|
|
26
|
+
if (ext === 'sd3' || ext === 'cl2' || ext === 'txt') return 'sdif-v3';
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
package/src/hy3.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hy-Tek (.hy3) adapter → NormalizedMeet.
|
|
3
|
+
*
|
|
4
|
+
* 130-char fixed-width records (128 data + 2 checksum), CP-1252, CRLF.
|
|
5
|
+
* Record types handled:
|
|
6
|
+
* A1 file · B1 meet · C1 team · D1 athlete
|
|
7
|
+
* E1 entry / E2 result (individual, paired)
|
|
8
|
+
* F1 entry / F2 result / F3 legs (relay)
|
|
9
|
+
* G1 splits · H1 DQ reason
|
|
10
|
+
*
|
|
11
|
+
* Two things HY3 gives us that SDIF does not: it RETAINS the swum time on a DQ,
|
|
12
|
+
* and it carries a structured DQ reason (H1). One thing it lacks: meet
|
|
13
|
+
* points — Hy-Tek computes standings separately — so result.points is 0 here and
|
|
14
|
+
* must be derived by a consumer from place + league scoring rules.
|
|
15
|
+
*
|
|
16
|
+
* Offsets for D1/E1/E2/F3 were verified empirically against a real GG-at-WW file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { HY3_STROKE, STROKE, GENDER_DISPLAY, ageGroup } from './constants.js';
|
|
20
|
+
import { timeFromSeconds, normalizeDate } from './times.js';
|
|
21
|
+
import { displayTeamCode, deriveSwimmers } from './model.js';
|
|
22
|
+
|
|
23
|
+
const slice = (line, a, b) => (line.length >= a ? line.slice(a, b).trim() : '');
|
|
24
|
+
const num = (s) => {
|
|
25
|
+
const v = parseFloat(s);
|
|
26
|
+
return Number.isNaN(v) || v <= 0 ? null : v;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// "Last, First M" — append the middle initial when present, matching how SDIF
|
|
30
|
+
// packs its D0 name field so the two formats produce identical display names.
|
|
31
|
+
const athleteName = (a) => {
|
|
32
|
+
if (!a) return '';
|
|
33
|
+
const base = a.first ? `${a.last}, ${a.first}` : a.last;
|
|
34
|
+
return a.middle ? `${base} ${a.middle}` : base;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Event-sex code (W/M/G/B/X) → canonical gender.
|
|
38
|
+
const EVENT_SEX = { M: 'M', B: 'M', W: 'F', G: 'F', F: 'F', X: 'X' };
|
|
39
|
+
|
|
40
|
+
// E2/F2 status char → canonical ResultStatus.
|
|
41
|
+
const STATUS = { ' ': 'ok', '': 'ok', Q: 'dq', F: 'ns', R: 'scratch', D: 'dnf', S: 'exhibition' };
|
|
42
|
+
|
|
43
|
+
// On a RELAY, HY3 reuses the stroke letters differently: A = Freestyle relay,
|
|
44
|
+
// E = Medley relay (vs individual E = IM). Map relays explicitly.
|
|
45
|
+
const HY3_RELAY_STROKE = { A: STROKE.FREESTYLE, E: STROKE.MEDLEY };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} content raw .hy3 text
|
|
49
|
+
* @returns {import('./model.js').NormalizedMeet}
|
|
50
|
+
*/
|
|
51
|
+
export function parseHy3(content) {
|
|
52
|
+
const lines = String(content).split(/\r?\n/);
|
|
53
|
+
|
|
54
|
+
/** @type {import('./model.js').MeetInfo} */
|
|
55
|
+
const meet = { name: '', rawName: '', startDate: null };
|
|
56
|
+
const teamMap = new Map();
|
|
57
|
+
const eventMap = new Map();
|
|
58
|
+
/** @type {Map<string, {last:string,first:string,gender:string,birthDate:string|null,usasId:string|null}>} */
|
|
59
|
+
const athletes = new Map();
|
|
60
|
+
const source = {};
|
|
61
|
+
|
|
62
|
+
let currentTeam = null;
|
|
63
|
+
let currentAthlete = null; // athlete number
|
|
64
|
+
let lastResult = null; // most recent individual or relay result (for splits / DQ reason)
|
|
65
|
+
let lastRelay = null; // most recent relay (for F3 legs)
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const line = lines[i];
|
|
69
|
+
const code = line.slice(0, 2);
|
|
70
|
+
try {
|
|
71
|
+
switch (code) {
|
|
72
|
+
case 'A1':
|
|
73
|
+
source.software = slice(line, 44, 58) || undefined;
|
|
74
|
+
source.createdAt = normalizeDate(slice(line, 58, 66));
|
|
75
|
+
break;
|
|
76
|
+
case 'B1':
|
|
77
|
+
meet.rawName = slice(line, 2, 47);
|
|
78
|
+
meet.name = meet.rawName;
|
|
79
|
+
meet.startDate = normalizeDate(slice(line, 92, 100));
|
|
80
|
+
break;
|
|
81
|
+
case 'C1': {
|
|
82
|
+
const raw = slice(line, 2, 7);
|
|
83
|
+
currentTeam = raw;
|
|
84
|
+
if (!teamMap.has(raw)) {
|
|
85
|
+
teamMap.set(raw, { code: displayTeamCode(raw), fullCode: raw, name: slice(line, 7, 37) });
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'D1': {
|
|
90
|
+
const anum = slice(line, 3, 8);
|
|
91
|
+
currentAthlete = anum;
|
|
92
|
+
athletes.set(anum, {
|
|
93
|
+
last: slice(line, 8, 28),
|
|
94
|
+
first: slice(line, 28, 48),
|
|
95
|
+
preferred: slice(line, 48, 68) || undefined,
|
|
96
|
+
middle: slice(line, 68, 69) || undefined,
|
|
97
|
+
gender: slice(line, 2, 3),
|
|
98
|
+
birthDate: normalizeDate(slice(line, 88, 96)),
|
|
99
|
+
usasId: slice(line, 69, 83) || null,
|
|
100
|
+
team: currentTeam,
|
|
101
|
+
});
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case 'E1': {
|
|
105
|
+
const e2 = lines[i + 1] && lines[i + 1].slice(0, 2) === 'E2' ? lines[i + 1] : '';
|
|
106
|
+
lastResult = parseIndividual(line, e2, eventMap, athletes, currentAthlete, teamMap);
|
|
107
|
+
lastRelay = null;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'F1': {
|
|
111
|
+
const f2 = lines[i + 1] && lines[i + 1].slice(0, 2) === 'F2' ? lines[i + 1] : '';
|
|
112
|
+
lastRelay = parseRelay(line, f2, eventMap, currentTeam, teamMap);
|
|
113
|
+
lastResult = lastRelay;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'F3':
|
|
117
|
+
if (lastRelay) parseRelayLegs(line, lastRelay, athletes);
|
|
118
|
+
break;
|
|
119
|
+
case 'H1':
|
|
120
|
+
if (lastResult && lastResult.disqualified) {
|
|
121
|
+
lastResult.dqCode = slice(line, 2, 4);
|
|
122
|
+
lastResult.dqReason = slice(line, 4, 52);
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
/* skip malformed line */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const events = [...eventMap.values()];
|
|
132
|
+
for (const ev of events) ev.results.sort((a, b) => (a.place ?? 999) - (b.place ?? 999));
|
|
133
|
+
const teams = [...teamMap.values()];
|
|
134
|
+
|
|
135
|
+
// Enrich the derived registry with athlete-level gender/usasId from D1.
|
|
136
|
+
const enrich = new Map();
|
|
137
|
+
for (const a of athletes.values()) {
|
|
138
|
+
const key = `${a.last}|${a.first}|${a.birthDate || ''}`.toLowerCase();
|
|
139
|
+
enrich.set(key, {
|
|
140
|
+
gender: a.gender === 'M' || a.gender === 'F' ? a.gender : undefined,
|
|
141
|
+
usasId: a.usasId,
|
|
142
|
+
preferredName: a.preferred,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const swimmers = deriveSwimmers(events, enrich);
|
|
146
|
+
|
|
147
|
+
return { format: 'hy3', source, meet, teams, swimmers, events };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseIndividual(e1, e2, eventMap, athletes, anum, teamMap) {
|
|
151
|
+
const eventNum = slice(e1, 38, 42);
|
|
152
|
+
const ev = ensureEvent(eventMap, buildEvent(e1, 'individual', { sex: [14, 15], dist: [15, 21], stroke: [21, 22], age: [22, 28] }), eventNum);
|
|
153
|
+
|
|
154
|
+
const a = athletes.get(anum) || { last: slice(e1, 8, 13), first: '', birthDate: null, team: null };
|
|
155
|
+
const swimmerName = athleteName(a);
|
|
156
|
+
const status = STATUS[e2 ? e2[12] : ' '] || 'ok';
|
|
157
|
+
const seconds = e2 ? num(e2.slice(4, 11)) : null;
|
|
158
|
+
const place = e2 ? parseInt(slice(e2, 30, 33), 10) : NaN;
|
|
159
|
+
|
|
160
|
+
/** @type {import('./model.js').IndividualResult} */
|
|
161
|
+
const result = {
|
|
162
|
+
kind: 'individual',
|
|
163
|
+
swimmerName,
|
|
164
|
+
teamCode: teamMap.get(a.team)?.code || a.team || '',
|
|
165
|
+
birthDate: a.birthDate,
|
|
166
|
+
seedTime: timeFromSeconds(num(e1.slice(52, 59))),
|
|
167
|
+
finalTime: timeFromSeconds(seconds), // retained even on DQ (HY3 keeps it)
|
|
168
|
+
status,
|
|
169
|
+
disqualified: status === 'dq',
|
|
170
|
+
place: Number.isNaN(place) ? null : place || null,
|
|
171
|
+
points: 0, // HY3 stores no points; deriving them is a consumer concern
|
|
172
|
+
};
|
|
173
|
+
ev.results.push(result);
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseRelay(f1, f2, eventMap, currentTeam, teamMap) {
|
|
178
|
+
const eventNum = slice(f1, 38, 42);
|
|
179
|
+
const ev = ensureEvent(eventMap, buildEvent(f1, 'relay', { sex: [14, 15], dist: [18, 21], stroke: [21, 22], age: [22, 28] }), eventNum);
|
|
180
|
+
|
|
181
|
+
const status = STATUS[f2 ? f2[12] : ' '] || 'ok';
|
|
182
|
+
const seconds = f2 ? num(f2.slice(5, 11)) : null;
|
|
183
|
+
const place = f2 ? parseInt(slice(f2, 30, 33), 10) : NaN;
|
|
184
|
+
|
|
185
|
+
/** @type {import('./model.js').RelayResult} */
|
|
186
|
+
const relay = {
|
|
187
|
+
kind: 'relay',
|
|
188
|
+
teamCode: teamMap.get(currentTeam)?.code || currentTeam || slice(f1, 2, 6),
|
|
189
|
+
relayLetter: slice(f1, 7, 8),
|
|
190
|
+
seedTime: timeFromSeconds(num(f1.slice(52, 59))),
|
|
191
|
+
finalTime: timeFromSeconds(seconds),
|
|
192
|
+
status,
|
|
193
|
+
disqualified: status === 'dq',
|
|
194
|
+
place: Number.isNaN(place) ? null : place || null,
|
|
195
|
+
points: 0,
|
|
196
|
+
legs: [],
|
|
197
|
+
};
|
|
198
|
+
ev.results.push(relay);
|
|
199
|
+
return relay;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseRelayLegs(f3, relay, athletes) {
|
|
203
|
+
// Up to 8 slots of 13 chars starting at col 3 (index 2).
|
|
204
|
+
for (let off = 2; off + 13 <= f3.length; off += 13) {
|
|
205
|
+
const slot = f3.slice(off, off + 13);
|
|
206
|
+
const anum = slot.slice(1, 6).trim();
|
|
207
|
+
if (!anum) continue;
|
|
208
|
+
const leg = parseInt(slot.slice(12, 13), 10);
|
|
209
|
+
const a = athletes.get(anum);
|
|
210
|
+
relay.legs.push({
|
|
211
|
+
name: a ? athleteName(a) : slot.slice(6, 11).trim(),
|
|
212
|
+
gender: a && (a.gender === 'M' || a.gender === 'F') ? a.gender : undefined,
|
|
213
|
+
legOrder: Number.isNaN(leg) ? relay.legs.length + 1 : leg,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildEvent(line, type, off) {
|
|
219
|
+
const gender = EVENT_SEX[line[off.sex[0]]] || 'X';
|
|
220
|
+
const distance = parseInt(slice(line, off.dist[0], off.dist[1]), 10) || 0;
|
|
221
|
+
const strokeCode = line[off.stroke[0]];
|
|
222
|
+
const strokeMap = type === 'relay' ? HY3_RELAY_STROKE : HY3_STROKE;
|
|
223
|
+
const stroke = strokeMap[strokeCode] || `Stroke ${strokeCode}`;
|
|
224
|
+
const ageRaw = slice(line, off.age[0], off.age[1]).split(/\s+/).filter(Boolean);
|
|
225
|
+
const ag = ageGroup(ageRaw[0], ageRaw[1]);
|
|
226
|
+
const agLabel = type === 'relay' && ag.label === 'Open' ? '' : ag.label;
|
|
227
|
+
const description = `${GENDER_DISPLAY[gender]} ${agLabel} ${distance}m ${stroke}${type === 'relay' ? ' Relay' : ''}`
|
|
228
|
+
.replace(/\s+/g, ' ')
|
|
229
|
+
.trim();
|
|
230
|
+
return { type, gender, distance, stroke, ageGroup: ag, description, results: [] };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function ensureEvent(eventMap, built, rawNumber) {
|
|
234
|
+
const numbered = rawNumber && rawNumber !== '0';
|
|
235
|
+
const key = numbered ? rawNumber : `u:${built.description}`;
|
|
236
|
+
let ev = eventMap.get(key);
|
|
237
|
+
if (!ev) {
|
|
238
|
+
ev = { number: numbered ? rawNumber : '', ...built };
|
|
239
|
+
eventMap.set(key, ev);
|
|
240
|
+
}
|
|
241
|
+
return ev;
|
|
242
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* swimparse — public entry point.
|
|
3
|
+
*
|
|
4
|
+
* Parse SDIF v3 (.sd3) or Hy-Tek (.hy3) meet results into one NormalizedMeet.
|
|
5
|
+
* Zero dependencies; runs in the browser, Node, and CI.
|
|
6
|
+
*
|
|
7
|
+
* import { parse, detectFormat } from 'swimparse';
|
|
8
|
+
* const meet = parse(fileText, { filename: 'GG_at_WW.hy3' });
|
|
9
|
+
*
|
|
10
|
+
* SCOPE: this library reads meet-result files and emits JSON. That is all it
|
|
11
|
+
* does. It has no concept of a league — no age bands, no scoring rules, no team
|
|
12
|
+
* registry, no qualifying standards. Those are league policy and belong to the
|
|
13
|
+
* application consuming this output.
|
|
14
|
+
*
|
|
15
|
+
* PRIVACY: the output is a lossless superset of the source file, so it CARRIES
|
|
16
|
+
* SWIMMER BIRTHDATES (`swimmers[].birthDate`, individual `results[].birthDate`)
|
|
17
|
+
* and USA-S registration ids. For youth meets that is PII for minors — treat
|
|
18
|
+
* every parse result as confidential until your application has stripped or
|
|
19
|
+
* aggregated it. swimparse deliberately does not do that for you: what counts as
|
|
20
|
+
* safe is a league decision (a summer league publishes age-group labels; a
|
|
21
|
+
* USA-Swimming tool needs exact ages), so it belongs to the consumer.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { parseSdif } from './sdif.js';
|
|
25
|
+
import { parseHy3 } from './hy3.js';
|
|
26
|
+
import { detectFormat } from './detect.js';
|
|
27
|
+
|
|
28
|
+
export { parseSdif } from './sdif.js';
|
|
29
|
+
export { parseHy3 } from './hy3.js';
|
|
30
|
+
export { detectFormat } from './detect.js';
|
|
31
|
+
export * from './model.js';
|
|
32
|
+
export * from './constants.js';
|
|
33
|
+
export * from './times.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parses meet-result text, auto-detecting the format unless one is given.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} content
|
|
39
|
+
* @param {Object} [opts]
|
|
40
|
+
* @param {'sdif-v3'|'hy3'} [opts.format] force a format, skipping detection
|
|
41
|
+
* @param {string} [opts.filename] used as a detection tie-breaker
|
|
42
|
+
* @returns {import('./model.js').NormalizedMeet}
|
|
43
|
+
*/
|
|
44
|
+
export function parse(content, opts = {}) {
|
|
45
|
+
const format = opts.format || detectFormat(content, opts.filename);
|
|
46
|
+
if (format === 'sdif-v3') return parseSdif(content);
|
|
47
|
+
if (format === 'hy3') return parseHy3(content);
|
|
48
|
+
throw new Error('swimparse: could not detect meet-result format (expected SDIF .sd3 or Hy-Tek .hy3)');
|
|
49
|
+
}
|
package/src/model.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The NormalizedMeet contract.
|
|
3
|
+
*
|
|
4
|
+
* The single shape both adapters produce, so consumers read one JSON contract
|
|
5
|
+
* instead of two fixed-width formats.
|
|
6
|
+
*
|
|
7
|
+
* Design rule: **lossless superset**. Capture every swim — placing or not,
|
|
8
|
+
* exhibition, DQ, no-show — plus birthdates, seed times, splits and DQ reasons.
|
|
9
|
+
* Consumers filter down to what they need. Never drop data at parse time.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is read out of the file. Nothing is inferred, computed, or
|
|
12
|
+
* relabelled according to any league's rules: no age banding, no scoring, no
|
|
13
|
+
* team-code canonicalization, no synthesized meet names. Those are consumer
|
|
14
|
+
* concerns.
|
|
15
|
+
*
|
|
16
|
+
* PRIVACY: `swimmers[].birthDate` and `usasId` are PII for minors, and the
|
|
17
|
+
* lossless rule means they are always populated when the file carries them. A
|
|
18
|
+
* NormalizedMeet is therefore a confidential artifact by default. Stripping or
|
|
19
|
+
* aggregating it is the consumer's job — see the privacy note in index.js.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {import('./times.js').SwimTime} SwimTime
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} NormalizedMeet
|
|
28
|
+
* @property {'sdif-v3'|'hy3'} format Source format the file was parsed from.
|
|
29
|
+
* @property {Object} source Producing software (A0/A1 record).
|
|
30
|
+
* @property {string} [source.software]
|
|
31
|
+
* @property {string} [source.version]
|
|
32
|
+
* @property {string} [source.createdAt] ISO date if available.
|
|
33
|
+
* @property {MeetInfo} meet
|
|
34
|
+
* @property {Team[]} teams
|
|
35
|
+
* @property {Swimmer[]} swimmers Deduped registry (name + birthdate).
|
|
36
|
+
* @property {Event[]} events
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} MeetInfo
|
|
41
|
+
* @property {string} name Meet name as it appeared in the file.
|
|
42
|
+
* @property {string} rawName Identical to `name`; kept so consumers
|
|
43
|
+
* that relabel a meet have an untouched
|
|
44
|
+
* original to fall back on.
|
|
45
|
+
* @property {string} [hostName]
|
|
46
|
+
* @property {string|null} startDate ISO "YYYY-MM-DD".
|
|
47
|
+
* @property {string|null} [endDate]
|
|
48
|
+
* @property {string|null} [course] 'SCY' | 'LCM' | 'SCM'.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} Team
|
|
53
|
+
* @property {string} code Display code, VA-prefix stripped (e.g. "WW").
|
|
54
|
+
* @property {string} fullCode Raw code as in the file (e.g. "VAWW").
|
|
55
|
+
* @property {string} name
|
|
56
|
+
* @property {string} [shortName]
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {Object} Swimmer
|
|
61
|
+
* @property {string} id Stable within-file id.
|
|
62
|
+
* @property {string} teamCode
|
|
63
|
+
* @property {string} lastName
|
|
64
|
+
* @property {string} firstName
|
|
65
|
+
* @property {string} [preferredName]
|
|
66
|
+
* @property {string} [middleInitial]
|
|
67
|
+
* @property {string} fullName "Last, First".
|
|
68
|
+
* @property {'M'|'F'} [gender]
|
|
69
|
+
* @property {string|null} [birthDate] ISO, or null. PII — as read from the file.
|
|
70
|
+
* @property {string|null} [usasId] PII — as read from the file.
|
|
71
|
+
* @property {number|null} [age] Age as stated in the file, when present.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {Object} Event
|
|
76
|
+
* @property {string} number Event number as a string (may be alphanumeric).
|
|
77
|
+
* @property {'individual'|'relay'} type
|
|
78
|
+
* @property {'M'|'F'|'X'} gender
|
|
79
|
+
* @property {number} distance
|
|
80
|
+
* @property {string} stroke Canonical stroke name.
|
|
81
|
+
* @property {string|null} [course]
|
|
82
|
+
* @property {{label:string, lower:number, upper:number}} ageGroup
|
|
83
|
+
* @property {string} description Human label, e.g. "Boys 15-18 100m IM".
|
|
84
|
+
* @property {(IndividualResult|RelayResult)[]} results
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {Object} IndividualResult
|
|
89
|
+
* @property {'individual'} kind
|
|
90
|
+
* @property {string} [swimmerId] Links to Swimmer.id when resolvable.
|
|
91
|
+
* @property {string} swimmerName "Last, First" as in the file.
|
|
92
|
+
* @property {string} teamCode
|
|
93
|
+
* @property {string|null} [birthDate] ISO. PII — as read from the file.
|
|
94
|
+
* @property {SwimTime|null} seedTime
|
|
95
|
+
* @property {SwimTime|null} finalTime The time swum. NOTE: for HY3 this is
|
|
96
|
+
* retained even on a DQ; for SDIF it is
|
|
97
|
+
* null on DQ/NS (the format nulls it).
|
|
98
|
+
* @property {import('./constants.js').ResultStatus} status
|
|
99
|
+
* @property {boolean} disqualified
|
|
100
|
+
* @property {string} [dqCode] HY3 only.
|
|
101
|
+
* @property {string} [dqReason] HY3 H1/H2 only.
|
|
102
|
+
* @property {number|null} place null = non-scoring / exhibition.
|
|
103
|
+
* @property {number} [heat]
|
|
104
|
+
* @property {number} [lane]
|
|
105
|
+
* @property {number} points Points as stored in the file (SDIF).
|
|
106
|
+
* HY3 carries none, so it reads 0 —
|
|
107
|
+
* deriving points is a scoring concern
|
|
108
|
+
* and belongs to the consumer.
|
|
109
|
+
* @property {number[]} [splits] Cumulative split seconds (HY3 G1).
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @typedef {Object} RelayResult
|
|
114
|
+
* @property {'relay'} kind
|
|
115
|
+
* @property {string} teamCode
|
|
116
|
+
* @property {string} relayLetter 'A', 'B', ...
|
|
117
|
+
* @property {SwimTime|null} seedTime
|
|
118
|
+
* @property {SwimTime|null} finalTime
|
|
119
|
+
* @property {import('./constants.js').ResultStatus} status
|
|
120
|
+
* @property {boolean} disqualified
|
|
121
|
+
* @property {string} [dqCode]
|
|
122
|
+
* @property {string} [dqReason]
|
|
123
|
+
* @property {number|null} place
|
|
124
|
+
* @property {number} [heat]
|
|
125
|
+
* @property {number} [lane]
|
|
126
|
+
* @property {number} points
|
|
127
|
+
* @property {RelayLeg[]} legs
|
|
128
|
+
* @property {number[]} [splits]
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @typedef {Object} RelayLeg
|
|
133
|
+
* @property {string} [swimmerId]
|
|
134
|
+
* @property {string} name
|
|
135
|
+
* @property {'M'|'F'} [gender]
|
|
136
|
+
* @property {number} [age]
|
|
137
|
+
* @property {number} legOrder 1-4 (or higher for alternates).
|
|
138
|
+
*/
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Strips a two-letter LSC/state prefix (e.g. "VAWW" → "WW") from a raw team code.
|
|
142
|
+
*
|
|
143
|
+
* This is an SDIF/HY3 file convention, not a league rule: US meet files prefix
|
|
144
|
+
* team codes with the LSC. `Team.fullCode` always keeps the raw value, so a
|
|
145
|
+
* consumer that wants the prefix back has it. Mapping either form onto a
|
|
146
|
+
* league's canonical code is the consumer's job.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} rawCode
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function displayTeamCode(rawCode) {
|
|
152
|
+
const c = (rawCode || '').trim();
|
|
153
|
+
return c.startsWith('VA') ? c.slice(2) : c;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Builds the deduped swimmer registry from individual results.
|
|
158
|
+
*
|
|
159
|
+
* Keyed on `lastName|firstName|birthDate`, the strongest identity the file
|
|
160
|
+
* itself supports (middle name is excluded because it drifts between exports).
|
|
161
|
+
* This is a **within-file** key only — durable cross-meet or cross-season
|
|
162
|
+
* athlete identity is a consumer concern and cannot be decided here.
|
|
163
|
+
*
|
|
164
|
+
* @param {Event[]} events
|
|
165
|
+
* @param {Map<string, Partial<Swimmer>>} [enrich] optional id→extra fields (D3/D1 data)
|
|
166
|
+
* @returns {Swimmer[]}
|
|
167
|
+
*/
|
|
168
|
+
export function deriveSwimmers(events, enrich) {
|
|
169
|
+
/** @type {Map<string, Swimmer>} */
|
|
170
|
+
const byKey = new Map();
|
|
171
|
+
for (const ev of events) {
|
|
172
|
+
if (ev.type !== 'individual') continue;
|
|
173
|
+
const gender = ev.gender === 'X' ? undefined : ev.gender;
|
|
174
|
+
for (const r of ev.results) {
|
|
175
|
+
const [last, first] = splitName(r.swimmerName);
|
|
176
|
+
const key = `${last}|${first}|${r.birthDate || ''}`.toLowerCase();
|
|
177
|
+
if (!byKey.has(key)) {
|
|
178
|
+
byKey.set(key, {
|
|
179
|
+
id: key,
|
|
180
|
+
teamCode: r.teamCode,
|
|
181
|
+
lastName: last,
|
|
182
|
+
firstName: first,
|
|
183
|
+
fullName: r.swimmerName,
|
|
184
|
+
gender,
|
|
185
|
+
birthDate: r.birthDate || null,
|
|
186
|
+
usasId: null,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
r.swimmerId = key;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (enrich) {
|
|
193
|
+
for (const s of byKey.values()) {
|
|
194
|
+
const extra = enrich.get(s.id);
|
|
195
|
+
if (extra) Object.assign(s, extra);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [...byKey.values()];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Splits "Last, First M" into ["Last", "First"] (middle dropped).
|
|
203
|
+
* @param {string} name
|
|
204
|
+
* @returns {[string, string]}
|
|
205
|
+
*/
|
|
206
|
+
export function splitName(name) {
|
|
207
|
+
const parts = String(name || '').split(',');
|
|
208
|
+
const last = (parts[0] || '').trim();
|
|
209
|
+
const first = (parts[1] || '').trim().split(/\s+/)[0] || '';
|
|
210
|
+
return [last, first];
|
|
211
|
+
}
|
package/src/sdif.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDIF v3 (.sd3) adapter → NormalizedMeet.
|
|
3
|
+
*
|
|
4
|
+
* Fixed-width text. Record types handled:
|
|
5
|
+
* B1 meet · B2 host · C1 team · D0 individual result · D3 swimmer reg
|
|
6
|
+
* E0 relay result · F0 relay swimmer
|
|
7
|
+
*
|
|
8
|
+
* Keeps EVERY individual/relay result (exhibition, DQ, no-show, non-placing)
|
|
9
|
+
* — the lossless-superset rule.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { SDIF_STROKE, GENDER, GENDER_DISPLAY, ageGroup } from './constants.js';
|
|
13
|
+
import { timeFromText, normalizeDate } from './times.js';
|
|
14
|
+
import { displayTeamCode, deriveSwimmers } from './model.js';
|
|
15
|
+
|
|
16
|
+
// Column offsets (0-indexed, [start, end)).
|
|
17
|
+
const OFF = {
|
|
18
|
+
D0: { name: [11, 39], birth: [55, 63], seed: [88, 96], final: [115, 123], event: [72, 76], place: [135, 138], points: [138, 142] },
|
|
19
|
+
D0evt: { gender: [66, 67], dist: [67, 71], stroke: [71, 72], age: [76, 80] },
|
|
20
|
+
E0: { letter: [11, 12], final: [72, 80], event: [26, 30], place: [92, 95], points: [95, 99] },
|
|
21
|
+
E0evt: { gender: [20, 21], dist: [21, 25], stroke: [25, 26], age: [30, 34] },
|
|
22
|
+
F0: { name: [22, 50] },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const slice = (line, [a, b]) => (line.length >= a ? line.slice(a, b).trim() : '');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} content raw .sd3 text
|
|
29
|
+
* @returns {import('./model.js').NormalizedMeet}
|
|
30
|
+
*/
|
|
31
|
+
export function parseSdif(content) {
|
|
32
|
+
const lines = String(content).split(/\r?\n/);
|
|
33
|
+
|
|
34
|
+
/** @type {import('./model.js').MeetInfo} */
|
|
35
|
+
const meet = { name: '', rawName: '', startDate: null };
|
|
36
|
+
/** @type {Map<string, import('./model.js').Team>} */
|
|
37
|
+
const teamMap = new Map();
|
|
38
|
+
/** @type {Map<string, import('./model.js').Event>} */
|
|
39
|
+
const eventMap = new Map();
|
|
40
|
+
|
|
41
|
+
let currentTeam = null; // raw code
|
|
42
|
+
let lastRelay = null;
|
|
43
|
+
|
|
44
|
+
const source = {};
|
|
45
|
+
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const code = line.slice(0, 2);
|
|
48
|
+
try {
|
|
49
|
+
switch (code) {
|
|
50
|
+
case 'A0':
|
|
51
|
+
source.software = slice(line, [43, 63]) || undefined;
|
|
52
|
+
break;
|
|
53
|
+
case 'B1':
|
|
54
|
+
meet.rawName = slice(line, [11, 41]);
|
|
55
|
+
meet.name = meet.rawName;
|
|
56
|
+
meet.startDate = normalizeDate(slice(line, [121, 129]));
|
|
57
|
+
lastRelay = null;
|
|
58
|
+
break;
|
|
59
|
+
case 'B2':
|
|
60
|
+
if (!meet.hostName) meet.hostName = slice(line, [11, 41]);
|
|
61
|
+
break;
|
|
62
|
+
case 'C1': {
|
|
63
|
+
const raw = slice(line, [11, 17]);
|
|
64
|
+
currentTeam = raw;
|
|
65
|
+
if (!teamMap.has(raw)) {
|
|
66
|
+
teamMap.set(raw, {
|
|
67
|
+
code: displayTeamCode(raw),
|
|
68
|
+
fullCode: raw,
|
|
69
|
+
name: slice(line, [17, 47]),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
lastRelay = null;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'D0':
|
|
76
|
+
lastRelay = null;
|
|
77
|
+
parseD0(line, eventMap, currentTeam, teamMap);
|
|
78
|
+
break;
|
|
79
|
+
case 'E0':
|
|
80
|
+
lastRelay = parseE0(line, eventMap, currentTeam, teamMap);
|
|
81
|
+
break;
|
|
82
|
+
case 'F0':
|
|
83
|
+
if (lastRelay) {
|
|
84
|
+
const name = slice(line, OFF.F0.name);
|
|
85
|
+
if (name) lastRelay.legs.push({ name, legOrder: lastRelay.legs.length + 1 });
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
/* skip malformed line */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const events = [...eventMap.values()];
|
|
95
|
+
for (const ev of events) {
|
|
96
|
+
ev.results.sort((a, b) => (a.place ?? 999) - (b.place ?? 999));
|
|
97
|
+
}
|
|
98
|
+
const teams = [...teamMap.values()];
|
|
99
|
+
const swimmers = deriveSwimmers(events);
|
|
100
|
+
|
|
101
|
+
return { format: 'sdif-v3', source, meet, teams, swimmers, events };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function statusFromText(raw) {
|
|
105
|
+
const t = String(raw).trim().toUpperCase();
|
|
106
|
+
if (t.startsWith('DQ')) return 'dq';
|
|
107
|
+
if (t.startsWith('NS')) return 'ns';
|
|
108
|
+
if (t.startsWith('DNF')) return 'dnf';
|
|
109
|
+
if (t.startsWith('SCR')) return 'scratch';
|
|
110
|
+
return 'ok';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseD0(line, eventMap, currentTeam, teamMap) {
|
|
114
|
+
if (!currentTeam) return;
|
|
115
|
+
// Event number 0 = an unseeded/unofficial event (e.g. 8 & Under "B" relays).
|
|
116
|
+
// Keep it (lossless), but bucket unnumbered events by description so two
|
|
117
|
+
// distinct ones don't merge under a shared "0".
|
|
118
|
+
const eventNum = slice(line, OFF.D0.event);
|
|
119
|
+
const ev = ensureEvent(eventMap, buildEvent(line, 'individual', OFF.D0evt), eventNum);
|
|
120
|
+
|
|
121
|
+
const finalRaw = slice(line, OFF.D0.final);
|
|
122
|
+
const status = statusFromText(finalRaw);
|
|
123
|
+
const placeNum = parseInt(slice(line, OFF.D0.place), 10);
|
|
124
|
+
|
|
125
|
+
/** @type {import('./model.js').IndividualResult} */
|
|
126
|
+
ev.results.push({
|
|
127
|
+
kind: 'individual',
|
|
128
|
+
swimmerName: slice(line, OFF.D0.name),
|
|
129
|
+
teamCode: teamMap.get(currentTeam)?.code || currentTeam,
|
|
130
|
+
birthDate: normalizeDate(slice(line, OFF.D0.birth)),
|
|
131
|
+
seedTime: timeFromText(slice(line, OFF.D0.seed)),
|
|
132
|
+
finalTime: status === 'ok' ? timeFromText(finalRaw) : null,
|
|
133
|
+
status,
|
|
134
|
+
disqualified: status === 'dq',
|
|
135
|
+
place: Number.isNaN(placeNum) ? null : placeNum || null,
|
|
136
|
+
points: parseFloat(slice(line, OFF.D0.points)) || 0,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseE0(line, eventMap, currentTeam, teamMap) {
|
|
141
|
+
if (!currentTeam) return null;
|
|
142
|
+
const eventNum = slice(line, OFF.E0.event);
|
|
143
|
+
const ev = ensureEvent(eventMap, buildEvent(line, 'relay', OFF.E0evt), eventNum);
|
|
144
|
+
|
|
145
|
+
const finalRaw = slice(line, OFF.E0.final);
|
|
146
|
+
const status = statusFromText(finalRaw);
|
|
147
|
+
const placeNum = parseInt(slice(line, OFF.E0.place), 10);
|
|
148
|
+
|
|
149
|
+
/** @type {import('./model.js').RelayResult} */
|
|
150
|
+
const relay = {
|
|
151
|
+
kind: 'relay',
|
|
152
|
+
teamCode: teamMap.get(currentTeam)?.code || currentTeam,
|
|
153
|
+
relayLetter: slice(line, OFF.E0.letter),
|
|
154
|
+
seedTime: null,
|
|
155
|
+
finalTime: status === 'ok' ? timeFromText(finalRaw) : null,
|
|
156
|
+
status,
|
|
157
|
+
disqualified: status === 'dq',
|
|
158
|
+
place: Number.isNaN(placeNum) ? null : placeNum || null,
|
|
159
|
+
points: parseFloat(slice(line, OFF.E0.points)) || 0,
|
|
160
|
+
legs: [],
|
|
161
|
+
};
|
|
162
|
+
ev.results.push(relay);
|
|
163
|
+
return relay;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildEvent(line, type, off) {
|
|
167
|
+
const gender = GENDER[slice(line, off.gender)] || 'X';
|
|
168
|
+
const distance = parseInt(slice(line, off.dist), 10) || 0;
|
|
169
|
+
const stroke = SDIF_STROKE[slice(line, off.stroke)] || `Stroke ${slice(line, off.stroke)}`;
|
|
170
|
+
const ageCode = slice(line, off.age).padEnd(4);
|
|
171
|
+
const ag = ageGroup(ageCode.slice(0, 2), ageCode.slice(2, 4));
|
|
172
|
+
const agLabel = type === 'relay' && ag.label === 'Open' ? '' : ag.label;
|
|
173
|
+
const description = `${GENDER_DISPLAY[gender]} ${agLabel} ${distance}m ${stroke}${type === 'relay' ? ' Relay' : ''}`
|
|
174
|
+
.replace(/\s+/g, ' ')
|
|
175
|
+
.trim();
|
|
176
|
+
return { type, gender, distance, stroke, ageGroup: ag, description, results: [] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function ensureEvent(eventMap, built, rawNumber) {
|
|
180
|
+
const numbered = rawNumber && rawNumber !== '0';
|
|
181
|
+
const key = numbered ? rawNumber : `u:${built.description}`;
|
|
182
|
+
let ev = eventMap.get(key);
|
|
183
|
+
if (!ev) {
|
|
184
|
+
ev = { number: numbered ? rawNumber : '', ...built };
|
|
185
|
+
eventMap.set(key, ev);
|
|
186
|
+
}
|
|
187
|
+
return ev;
|
|
188
|
+
}
|
package/src/times.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time and date helpers.
|
|
3
|
+
*
|
|
4
|
+
* The two source formats encode times differently — SDIF as display text
|
|
5
|
+
* ("1:11.35"), HY3 as raw seconds ("71.35") — but a NormalizedMeet always
|
|
6
|
+
* carries BOTH: `{ text, seconds }`. These helpers convert either way.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} SwimTime
|
|
11
|
+
* @property {string} text Canonical display, e.g. "1:11.35" or "28.05".
|
|
12
|
+
* @property {number} seconds Total seconds as a float (hundredths precision).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parses an SDIF-style time string into seconds.
|
|
17
|
+
* Handles "MM:SS.ss", "SS.ss", optional trailing course letter, and the
|
|
18
|
+
* non-time sentinels DQ / NS / SCR / DNF / NT (→ null).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} raw
|
|
21
|
+
* @returns {number|null} seconds, or null if not a real time
|
|
22
|
+
*/
|
|
23
|
+
export function textToSeconds(raw) {
|
|
24
|
+
if (!raw) return null;
|
|
25
|
+
const s = String(raw).trim().replace(/[A-Za-z]+$/, '').trim(); // strip trailing course flag
|
|
26
|
+
if (!s) return null;
|
|
27
|
+
if (/^(DQ|NS|SCR|DNF|NT)$/i.test(String(raw).trim())) return null;
|
|
28
|
+
if (s.includes(':')) {
|
|
29
|
+
const [m, rest] = s.split(':');
|
|
30
|
+
const minutes = parseInt(m, 10);
|
|
31
|
+
const seconds = parseFloat(rest);
|
|
32
|
+
if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
|
|
33
|
+
return round2(minutes * 60 + seconds);
|
|
34
|
+
}
|
|
35
|
+
const v = parseFloat(s);
|
|
36
|
+
return Number.isNaN(v) || v <= 0 ? null : round2(v);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Formats seconds as canonical display text ("1:11.35", "28.05").
|
|
41
|
+
* @param {number|null} seconds
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function secondsToText(seconds) {
|
|
45
|
+
if (seconds == null || Number.isNaN(seconds) || seconds <= 0) return '';
|
|
46
|
+
const total = round2(seconds);
|
|
47
|
+
const m = Math.floor(total / 60);
|
|
48
|
+
const s = total - m * 60;
|
|
49
|
+
if (m > 0) return `${m}:${s.toFixed(2).padStart(5, '0')}`;
|
|
50
|
+
return s.toFixed(2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds a SwimTime from seconds (HY3 path). Returns null if not a real time.
|
|
55
|
+
* @param {number|null} seconds
|
|
56
|
+
* @returns {SwimTime|null}
|
|
57
|
+
*/
|
|
58
|
+
export function timeFromSeconds(seconds) {
|
|
59
|
+
if (seconds == null || Number.isNaN(seconds) || seconds <= 0) return null;
|
|
60
|
+
return { text: secondsToText(seconds), seconds: round2(seconds) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds a SwimTime from display text (SDIF path). Returns null if not a real time.
|
|
65
|
+
* @param {string} text
|
|
66
|
+
* @returns {SwimTime|null}
|
|
67
|
+
*/
|
|
68
|
+
export function timeFromText(text) {
|
|
69
|
+
const seconds = textToSeconds(text);
|
|
70
|
+
return seconds == null ? null : { text: secondsToText(seconds), seconds };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normalizes an MMDDYYYY (or MMDDYY) date to ISO "YYYY-MM-DD".
|
|
75
|
+
* Two-digit years are windowed: <30 → 20xx, else 19xx.
|
|
76
|
+
* @param {string} raw
|
|
77
|
+
* @returns {string|null}
|
|
78
|
+
*/
|
|
79
|
+
export function normalizeDate(raw) {
|
|
80
|
+
if (!raw) return null;
|
|
81
|
+
const d = String(raw).trim();
|
|
82
|
+
if (d.length === 8) {
|
|
83
|
+
const mm = d.slice(0, 2), dd = d.slice(2, 4), yyyy = d.slice(4, 8);
|
|
84
|
+
if (yyyy === '0000' || mm === '00') return null;
|
|
85
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
86
|
+
}
|
|
87
|
+
if (d.length === 6) {
|
|
88
|
+
const mm = d.slice(0, 2), dd = d.slice(2, 4), yy = parseInt(d.slice(4, 6), 10);
|
|
89
|
+
if (Number.isNaN(yy)) return null;
|
|
90
|
+
const yyyy = yy < 30 ? 2000 + yy : 1900 + yy;
|
|
91
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Rounds to hundredths, avoiding binary-float noise. */
|
|
97
|
+
export function round2(n) {
|
|
98
|
+
return Math.round((n + Number.EPSILON) * 100) / 100;
|
|
99
|
+
}
|