edfcore 0.1.16 → 0.1.18

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/CHANGELOG.md ADDED
@@ -0,0 +1,119 @@
1
+ # Changelog
2
+
3
+ Notable changes per release. Fixes say what was wrong and what it cost, because a version number
4
+ alone does not tell you whether you were affected.
5
+
6
+ edfcore is pre-1.0. Patch releases have carried behaviour changes where the old behaviour was a
7
+ defect; those are called out below.
8
+
9
+ ## 0.1.18
10
+
11
+ - **Added** this changelog, and documentation for the CLI.
12
+
13
+ ## 0.1.17
14
+
15
+ - **Added** a CLI: `npx edfcore header|validate|events|json <file>`. Exit codes are the contract —
16
+ 0 success, 1 unreadable or failed validation, 2 bad usage — so `edfcore validate` gates a CI job
17
+ without parsing output. Patient identification is opt-in.
18
+
19
+ ## 0.1.16
20
+
21
+ - **Added** an exhaustiveness check to the public API test. It was an allowlist, which only proved
22
+ that listed exports exist; fifteen helpers reached npm undocumented because nothing failed.
23
+
24
+ ## 0.1.15
25
+
26
+ - **Fixed** `readEnvelope` reporting `precededByGap: undefined` on every chunk, so an EDF+D
27
+ envelope claimed no gaps. Introduced in 0.1.7. At one bucket per pixel a gap is invisible in the
28
+ data itself, so a viewer had nothing to go on.
29
+
30
+ ## 0.1.14
31
+
32
+ - **Added** [API — helpers](https://edfcore.vercel.app/docs/api-helpers), documenting everything
33
+ shipped in 0.1.7 through 0.1.13.
34
+
35
+ ## 0.1.13
36
+
37
+ - **Added** `formatValidationReport` to `edfcore/validate`.
38
+
39
+ ## 0.1.12
40
+
41
+ - **Added** `formatHeader`. Omits patient identification unless asked.
42
+
43
+ ## 0.1.11
44
+
45
+ - **Added** `sampleIndexAt`, `sampleStartTicks`, `sampleStartSeconds`. `Math.round(seconds *
46
+ sampleRateHz)` drifts by one over a long recording when the rate has no exact float
47
+ representation, and yields `NaN` for a zero record duration.
48
+
49
+ ## 0.1.10
50
+
51
+ - **Added** `filterAnnotationsByTime`, `filterAnnotationsByText`, `countAnnotationsByText`. All
52
+ comparisons are on `onsetTicks`, not on float seconds.
53
+
54
+ ## 0.1.9
55
+
56
+ - **Added** `streamRecords`, an async iterator over a window with bounded memory.
57
+
58
+ ## 0.1.8
59
+
60
+ - **Added** BioSemi Status-channel helpers: `getStatusSignal`, `decodeStatusWord`, `readTriggers`.
61
+ One event per change of the trigger word, not one per sample.
62
+
63
+ ## 0.1.7
64
+
65
+ - **Added** min/max envelope decimation: `readEnvelope`, `toPhysicalEnvelope`,
66
+ `envelopeOfSamples`. `toPhysicalEnvelope` swaps the bounds when the gain is negative, which
67
+ `toPhysical` applied twice would not.
68
+
69
+ ## 0.1.6
70
+
71
+ - **Fixed** `validateRecording` throwing `RangeError: Maximum call stack size exceeded` once a
72
+ sweep collected about 125,000 diagnostics. `push(...array)` passes each element as a call
73
+ argument. A 32 MiB file with 130,000 records and a damaged annotation section reached it
74
+ honestly, and the thrown value was neither an `EdfError` nor a caller mistake.
75
+ - **Fixed** record onsets wrapping silently in their `BigInt64Array` for an overflowing record
76
+ duration, producing an index that reported `coverage: 'complete'` with one segment per record,
77
+ negative gaps, and no diagnostic. A declared span past the tick range is now refused with a new
78
+ fatal `RECORDING_SPAN_UNREPRESENTABLE`.
79
+
80
+ ## 0.1.5
81
+
82
+ - **Changed** `readWindow` to validate `signalIndices` before resolving the window. An
83
+ out-of-range index previously threw for a window over data and returned `[]` for a window past
84
+ the end — and `[]` is documented to mean "no records here", never "the read failed".
85
+
86
+ ## 0.1.4
87
+
88
+ - **Fixed** a record whose timekeeping TAL is missing deriving its onset from zero rather than
89
+ from the recording's start. Three failures came from this one gap: a missing TAL in the last
90
+ record faked a discontinuity and made `readWindow` refuse an entire conforming file; the scan
91
+ chunk size changed the onsets, the segments and even a fatal `TIMELINE_NOT_MONOTONIC`; and the
92
+ same record reported two different start times depending on how many neighbours were read with
93
+ it.
94
+
95
+ ## 0.1.3
96
+
97
+ - **Fixed** `cachedSource` returning fabricated zero bytes when `blockBytes` or `maxBytes` was
98
+ `NaN`. The `< 1` guard cannot fire for `NaN`.
99
+ - **Fixed** `httpSource` hanging forever on a `NaN` `maxConcurrency` — no error, no timeout.
100
+ - **Fixed** `httpSource` re-downloading a Range-ignoring resource once per concurrent read. 32
101
+ concurrent reads cost 32 full transfers; now one.
102
+ - **Fixed** a source-level `AbortSignalLike` shim being ignored, so the same object behaved
103
+ differently depending on where it was passed.
104
+ - **Fixed** `validateRecording` allocating its scan scratch buffer without checking
105
+ `maxMaterializeBytes`, reachable as 400 MB from one corrupted field in a 512-byte file.
106
+
107
+ ## 0.1.2
108
+
109
+ - Release tooling only.
110
+
111
+ ## 0.1.1
112
+
113
+ - **Fixed** a false claim in the README: `strictNullChecks` does not catch an unguarded
114
+ `toPhysical` call. The type stops you reading the gain; it does not gate the call.
115
+ - **Fixed** three README links that 404ed on npmjs.com.
116
+
117
+ ## 0.1.0
118
+
119
+ First release. Reads EDF, EDF+, BDF and BDF+ with real random access.
package/README.md CHANGED
@@ -7,6 +7,17 @@ Node. Real random access, no dependencies.
7
7
  npm install edfcore
8
8
  ```
9
9
 
10
+ ## Look at a file without writing code
11
+
12
+ ```bash
13
+ npx edfcore header overnight.edf # signals, rates, ranges, diagnostics
14
+ npx edfcore events hypnogram.edf # annotations, counted by text
15
+ npx edfcore validate overnight.edf # full conformance sweep; exits 1 on failure
16
+ npx edfcore json overnight.edf # the header as JSON, for jq
17
+ ```
18
+
19
+ Patient identification is omitted unless you pass `--patient`.
20
+
10
21
  ## How do I read an EDF file in JavaScript?
11
22
 
12
23
  Open a source, pick a signal, read a time window, convert it to physical units. In the browser,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What `npx edfcore` actually does, with the process factored out.
3
+ *
4
+ * `cli.ts` is a shell that supplies real `node:fs` and `node:process`; everything decidable lives
5
+ * here, behind an injected `CliIo`. That is not ceremony — a CLI tested by spawning a subprocess
6
+ * can only be tested once the package is built, so the tests either skip in CI or test a stale
7
+ * binary. This way the exit codes and the output are ordinary unit tests.
8
+ */
9
+ /** Everything the CLI touches outside itself. */
10
+ export interface CliIo {
11
+ readFile(path: string): Promise<Uint8Array>;
12
+ out(text: string): void;
13
+ err(text: string): void;
14
+ }
15
+ export interface Args {
16
+ readonly command: string | undefined;
17
+ readonly file: string | undefined;
18
+ readonly patient: boolean;
19
+ readonly limit: number | undefined;
20
+ }
21
+ export declare function parseArgs(argv: readonly string[]): Args;
22
+ export declare function runCli(args: Args, io: CliIo): Promise<number>;
23
+ //# sourceMappingURL=cli-run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-run.d.ts","sourceRoot":"","sources":["../src/cli-run.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAUH,iDAAiD;AACjD,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC5C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAgBD,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;CACpC;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAoBvD;AAQD,wBAAsB,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAoFnE"}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What `npx edfcore` actually does, with the process factored out.
3
+ *
4
+ * `cli.ts` is a shell that supplies real `node:fs` and `node:process`; everything decidable lives
5
+ * here, behind an injected `CliIo`. That is not ceremony — a CLI tested by spawning a subprocess
6
+ * can only be tested once the package is built, so the tests either skip in CI or test a stale
7
+ * binary. This way the exit codes and the output are ordinary unit tests.
8
+ */
9
+ import { countAnnotationsByText } from './annotations-query.js';
10
+ import { formatDiagnostics } from './diagnostics/format.js';
11
+ import { formatHeader } from './format-header.js';
12
+ import { formatValidationReport } from './format-report.js';
13
+ import { byteSource } from './io/bytes.js';
14
+ import { openEdf, readAnnotations } from './recording.js';
15
+ import { validateRecording } from './validate.js';
16
+ const USAGE = `edfcore — read EDF, EDF+, BDF and BDF+ files
17
+
18
+ npx edfcore header <file> the header, the signals, and any diagnostics
19
+ npx edfcore validate <file> a full conformance sweep, scanning every sample
20
+ npx edfcore events <file> the annotations, counted by text
21
+ npx edfcore json <file> the header as JSON, for piping into jq
22
+
23
+ Options
24
+ --patient include patient identification (header, json)
25
+ --limit <n> individual diagnostics to print (default 20)
26
+
27
+ Exit codes: 0 success, 1 the file is unreadable or failed validation, 2 bad usage.
28
+ `;
29
+ export function parseArgs(argv) {
30
+ const positional = [];
31
+ let patient = false;
32
+ let limit;
33
+ for (let i = 0; i < argv.length; i += 1) {
34
+ const arg = argv[i];
35
+ if (arg === '--patient')
36
+ patient = true;
37
+ else if (arg === '--limit') {
38
+ const value = Number(argv[i + 1]);
39
+ // A NaN limit would disable the cap silently, which is the opposite of what was asked for.
40
+ if (!Number.isSafeInteger(value) || value < 0) {
41
+ throw new RangeError(`--limit needs a whole number, received ${String(argv[i + 1])}`);
42
+ }
43
+ limit = value;
44
+ i += 1;
45
+ }
46
+ else if (arg !== undefined && !arg.startsWith('-'))
47
+ positional.push(arg);
48
+ }
49
+ return { command: positional[0], file: positional[1], patient, limit };
50
+ }
51
+ async function open(io, file) {
52
+ // Read whole rather than fileSource: a CLI invocation is one pass over one file, and holding
53
+ // it in memory removes any question of a descriptor outliving the process.
54
+ return openEdf(byteSource(await io.readFile(file)));
55
+ }
56
+ export async function runCli(args, io) {
57
+ const { command, file } = args;
58
+ if (command === undefined || command === 'help' || command === '--help') {
59
+ io.out(USAGE);
60
+ return command === undefined ? 2 : 0;
61
+ }
62
+ if (file === undefined) {
63
+ io.err(`edfcore ${command}: no file given\n\n${USAGE}`);
64
+ return 2;
65
+ }
66
+ switch (command) {
67
+ case 'header': {
68
+ const recording = await open(io, file);
69
+ io.out(`${formatHeader(recording.header, { includePatientId: args.patient })}\n`);
70
+ if (recording.header.diagnostics.length > 0) {
71
+ io.out(`\n${formatDiagnostics(recording.header.diagnostics, { maxItems: args.limit ?? 20 })}\n`);
72
+ }
73
+ return 0;
74
+ }
75
+ case 'validate': {
76
+ const recording = await open(io, file);
77
+ const report = await validateRecording(recording, { scanSamples: true });
78
+ io.out(`${formatValidationReport(report, { header: recording.header, maxItems: args.limit ?? 20 })}\n`);
79
+ // Exit 1 on failure so a CI job can gate on it without parsing the output.
80
+ return report.ok ? 0 : 1;
81
+ }
82
+ case 'events': {
83
+ const recording = await open(io, file);
84
+ const { annotations } = await readAnnotations(recording, {
85
+ start: 0,
86
+ count: recording.header.recordCount,
87
+ });
88
+ if (annotations.length === 0) {
89
+ io.out('no annotations\n');
90
+ return 0;
91
+ }
92
+ io.out(`${annotations.length} annotation(s)\n\n`);
93
+ for (const { text, count } of countAnnotationsByText(annotations)) {
94
+ io.out(`${String(count).padStart(8)} ${text}\n`);
95
+ }
96
+ return 0;
97
+ }
98
+ case 'json': {
99
+ const recording = await open(io, file);
100
+ const { header } = recording;
101
+ io.out(`${JSON.stringify({
102
+ variant: header.variant,
103
+ recordCount: header.recordCount,
104
+ recordDurationSeconds: header.recordDurationSeconds,
105
+ spanSeconds: recording.timeline.spanSeconds,
106
+ // Patient identification is opt-in here for the same reason it is in formatHeader:
107
+ // the obvious thing to do with this output is pipe it somewhere.
108
+ ...(args.patient ? { patient: header.patient.raw.trim() } : {}),
109
+ signals: header.signals.map((signal) => ({
110
+ index: signal.index,
111
+ label: signal.label,
112
+ kind: signal.kind,
113
+ samplesPerRecord: signal.samplesPerRecord,
114
+ sampleRateHz: signal.sampleRateHz,
115
+ physicalDimension: signal.physicalDimension,
116
+ })),
117
+ diagnostics: header.diagnostics.map((d) => ({ code: d.code, severity: d.severity })),
118
+ }, null, 2)}\n`);
119
+ return 0;
120
+ }
121
+ default:
122
+ io.err(`edfcore: unknown command ${JSON.stringify(command)}\n\n${USAGE}`);
123
+ return 2;
124
+ }
125
+ }
126
+ //# sourceMappingURL=cli-run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-run.js","sourceRoot":"","sources":["../src/cli-run.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx edfcore` — looking at a file without writing code.
4
+ *
5
+ * A separate entry point, never re-exported from the barrel, because it imports `node:fs` and
6
+ * `node:process`. The universal entry must stay free of Node built-ins, and a test asserts it.
7
+ *
8
+ * This file is only the wiring. The decisions live in `cli-run.ts`, so they can be tested without
9
+ * spawning a process against a build that may not exist yet.
10
+ *
11
+ * `main()` runs unconditionally rather than behind an `import.meta.url === process.argv[1]` guard.
12
+ * That guard is a known trap for a bin: `npx` runs the command through a symlink, so the two paths
13
+ * differ, the condition is false, and the CLI exits 0 having done nothing.
14
+ */
15
+ export {};
16
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;GAYG"}
package/dist/cli.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx edfcore` — looking at a file without writing code.
4
+ *
5
+ * A separate entry point, never re-exported from the barrel, because it imports `node:fs` and
6
+ * `node:process`. The universal entry must stay free of Node built-ins, and a test asserts it.
7
+ *
8
+ * This file is only the wiring. The decisions live in `cli-run.ts`, so they can be tested without
9
+ * spawning a process against a build that may not exist yet.
10
+ *
11
+ * `main()` runs unconditionally rather than behind an `import.meta.url === process.argv[1]` guard.
12
+ * That guard is a known trap for a bin: `npx` runs the command through a symlink, so the two paths
13
+ * differ, the condition is false, and the CLI exits 0 having done nothing.
14
+ */
15
+ // biome-ignore lint/suspicious/noTsIgnore: @ts-expect-error errors when unused — see node.ts.
16
+ // @ts-ignore 'node:fs/promises' has no declarations under `types: []`; its shape is below.
17
+ import * as nodeFsPromises from 'node:fs/promises';
18
+ // biome-ignore lint/suspicious/noTsIgnore: as above.
19
+ // @ts-ignore 'node:process' has no declarations under `types: []`; its shape is below.
20
+ import * as nodeProcess from 'node:process';
21
+ import { parseArgs, runCli } from './cli-run.js';
22
+ import { isEdfError } from './errors.js';
23
+ const fs = nodeFsPromises;
24
+ const proc = nodeProcess.default ??
25
+ nodeProcess;
26
+ const io = {
27
+ readFile: (path) => fs.readFile(path),
28
+ out: (text) => {
29
+ proc.stdout.write(text);
30
+ },
31
+ err: (text) => {
32
+ proc.stderr.write(text);
33
+ },
34
+ };
35
+ async function main() {
36
+ try {
37
+ proc.exitCode = await runCli(parseArgs(proc.argv.slice(2)), io);
38
+ }
39
+ catch (error) {
40
+ // An EdfError already says what is wrong, where, and what to do next; a stack trace over the
41
+ // top of it would bury the one useful line.
42
+ const message = isEdfError(error) || error instanceof Error ? error.message : String(error);
43
+ proc.stderr.write(`edfcore: ${message}\n`);
44
+ proc.exitCode = 1;
45
+ }
46
+ }
47
+ // Not `await main()`: the package documents that no module in its graph uses top-level await,
48
+ // which is what keeps require() safe on Node >= 22.12. main() catches everything, so it cannot
49
+ // reject, and Node keeps the process alive until it settles.
50
+ void main();
51
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;GAYG;AAEH,8FAA8F;AAC9F,2FAA2F;AAC3F,OAAO,KAAK,cAAc,MAAM,kBAAkB,CAAC;AACnD,qDAAqD;AACrD,uFAAuF;AACvF,OAAO,KAAK,WAAW,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAc,SAAS,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAYzC,MAAM,EAAE,GAAW,cAAmC,CAAC;AACvD,MAAM,IAAI,GACP,WAAoD,CAAC,OAAO;IAC5D,WAAsC,CAAC;AAE1C,MAAM,EAAE,GAAU;IAChB,QAAQ,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC;IACrC,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE;QACZ,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IACD,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE;QACZ,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;CACF,CAAC;AAEF,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,IAAI,CAAC,QAAQ,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAClE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,6FAA6F;QAC7F,4CAA4C;QAC5C,MAAM,OAAO,GAAG,UAAU,CAAC,KAAK,CAAC,IAAI,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC5F,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,OAAO,IAAI,CAAC,CAAC;QAC3C,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC;IACpB,CAAC;AACH,CAAC;AAED,8FAA8F;AAC9F,+FAA+F;AAC/F,6DAA6D;AAC7D,KAAK,IAAI,EAAE,CAAC"}
@@ -109,5 +109,5 @@ export declare const SIGNAL_FIELD_BLOCK_OFFSETS: {
109
109
  readonly reserved: 224;
110
110
  };
111
111
  /** Published package version. Kept in sync with package.json by a test. */
112
- export declare const VERSION = "0.1.16";
112
+ export declare const VERSION = "0.1.18";
113
113
  //# sourceMappingURL=constants.d.ts.map
package/dist/constants.js CHANGED
@@ -79,5 +79,5 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
79
79
  reserved: 224,
80
80
  };
81
81
  /** Published package version. Kept in sync with package.json by a test. */
82
- export const VERSION = '0.1.16';
82
+ export const VERSION = '0.1.18';
83
83
  //# sourceMappingURL=constants.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edfcore",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Modern, typed, zero-dependency reader for EDF, EDF+, BDF and BDF+ biosignal files. Works in browsers and Node with true random access.",
5
5
  "keywords": [
6
6
  "edf",
@@ -32,6 +32,7 @@
32
32
  "type": "module",
33
33
  "sideEffects": false,
34
34
  "files": [
35
+ "CHANGELOG.md",
35
36
  "dist",
36
37
  "src"
37
38
  ],
@@ -74,5 +75,8 @@
74
75
  },
75
76
  "publishConfig": {
76
77
  "access": "public"
78
+ },
79
+ "bin": {
80
+ "edfcore": "./dist/cli.js"
77
81
  }
78
82
  }
package/src/cli-run.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * What `npx edfcore` actually does, with the process factored out.
3
+ *
4
+ * `cli.ts` is a shell that supplies real `node:fs` and `node:process`; everything decidable lives
5
+ * here, behind an injected `CliIo`. That is not ceremony — a CLI tested by spawning a subprocess
6
+ * can only be tested once the package is built, so the tests either skip in CI or test a stale
7
+ * binary. This way the exit codes and the output are ordinary unit tests.
8
+ */
9
+
10
+ import { countAnnotationsByText } from './annotations-query.js';
11
+ import { formatDiagnostics } from './diagnostics/format.js';
12
+ import { formatHeader } from './format-header.js';
13
+ import { formatValidationReport } from './format-report.js';
14
+ import { byteSource } from './io/bytes.js';
15
+ import { openEdf, readAnnotations } from './recording.js';
16
+ import { validateRecording } from './validate.js';
17
+
18
+ /** Everything the CLI touches outside itself. */
19
+ export interface CliIo {
20
+ readFile(path: string): Promise<Uint8Array>;
21
+ out(text: string): void;
22
+ err(text: string): void;
23
+ }
24
+
25
+ const USAGE = `edfcore — read EDF, EDF+, BDF and BDF+ files
26
+
27
+ npx edfcore header <file> the header, the signals, and any diagnostics
28
+ npx edfcore validate <file> a full conformance sweep, scanning every sample
29
+ npx edfcore events <file> the annotations, counted by text
30
+ npx edfcore json <file> the header as JSON, for piping into jq
31
+
32
+ Options
33
+ --patient include patient identification (header, json)
34
+ --limit <n> individual diagnostics to print (default 20)
35
+
36
+ Exit codes: 0 success, 1 the file is unreadable or failed validation, 2 bad usage.
37
+ `;
38
+
39
+ export interface Args {
40
+ readonly command: string | undefined;
41
+ readonly file: string | undefined;
42
+ readonly patient: boolean;
43
+ readonly limit: number | undefined;
44
+ }
45
+
46
+ export function parseArgs(argv: readonly string[]): Args {
47
+ const positional: string[] = [];
48
+ let patient = false;
49
+ let limit: number | undefined;
50
+
51
+ for (let i = 0; i < argv.length; i += 1) {
52
+ const arg = argv[i];
53
+ if (arg === '--patient') patient = true;
54
+ else if (arg === '--limit') {
55
+ const value = Number(argv[i + 1]);
56
+ // A NaN limit would disable the cap silently, which is the opposite of what was asked for.
57
+ if (!Number.isSafeInteger(value) || value < 0) {
58
+ throw new RangeError(`--limit needs a whole number, received ${String(argv[i + 1])}`);
59
+ }
60
+ limit = value;
61
+ i += 1;
62
+ } else if (arg !== undefined && !arg.startsWith('-')) positional.push(arg);
63
+ }
64
+
65
+ return { command: positional[0], file: positional[1], patient, limit };
66
+ }
67
+
68
+ async function open(io: CliIo, file: string) {
69
+ // Read whole rather than fileSource: a CLI invocation is one pass over one file, and holding
70
+ // it in memory removes any question of a descriptor outliving the process.
71
+ return openEdf(byteSource(await io.readFile(file)));
72
+ }
73
+
74
+ export async function runCli(args: Args, io: CliIo): Promise<number> {
75
+ const { command, file } = args;
76
+ if (command === undefined || command === 'help' || command === '--help') {
77
+ io.out(USAGE);
78
+ return command === undefined ? 2 : 0;
79
+ }
80
+ if (file === undefined) {
81
+ io.err(`edfcore ${command}: no file given\n\n${USAGE}`);
82
+ return 2;
83
+ }
84
+
85
+ switch (command) {
86
+ case 'header': {
87
+ const recording = await open(io, file);
88
+ io.out(`${formatHeader(recording.header, { includePatientId: args.patient })}\n`);
89
+ if (recording.header.diagnostics.length > 0) {
90
+ io.out(
91
+ `\n${formatDiagnostics(recording.header.diagnostics, { maxItems: args.limit ?? 20 })}\n`,
92
+ );
93
+ }
94
+ return 0;
95
+ }
96
+
97
+ case 'validate': {
98
+ const recording = await open(io, file);
99
+ const report = await validateRecording(recording, { scanSamples: true });
100
+ io.out(
101
+ `${formatValidationReport(report, { header: recording.header, maxItems: args.limit ?? 20 })}\n`,
102
+ );
103
+ // Exit 1 on failure so a CI job can gate on it without parsing the output.
104
+ return report.ok ? 0 : 1;
105
+ }
106
+
107
+ case 'events': {
108
+ const recording = await open(io, file);
109
+ const { annotations } = await readAnnotations(recording, {
110
+ start: 0,
111
+ count: recording.header.recordCount,
112
+ });
113
+ if (annotations.length === 0) {
114
+ io.out('no annotations\n');
115
+ return 0;
116
+ }
117
+ io.out(`${annotations.length} annotation(s)\n\n`);
118
+ for (const { text, count } of countAnnotationsByText(annotations)) {
119
+ io.out(`${String(count).padStart(8)} ${text}\n`);
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ case 'json': {
125
+ const recording = await open(io, file);
126
+ const { header } = recording;
127
+ io.out(
128
+ `${JSON.stringify(
129
+ {
130
+ variant: header.variant,
131
+ recordCount: header.recordCount,
132
+ recordDurationSeconds: header.recordDurationSeconds,
133
+ spanSeconds: recording.timeline.spanSeconds,
134
+ // Patient identification is opt-in here for the same reason it is in formatHeader:
135
+ // the obvious thing to do with this output is pipe it somewhere.
136
+ ...(args.patient ? { patient: header.patient.raw.trim() } : {}),
137
+ signals: header.signals.map((signal) => ({
138
+ index: signal.index,
139
+ label: signal.label,
140
+ kind: signal.kind,
141
+ samplesPerRecord: signal.samplesPerRecord,
142
+ sampleRateHz: signal.sampleRateHz,
143
+ physicalDimension: signal.physicalDimension,
144
+ })),
145
+ diagnostics: header.diagnostics.map((d) => ({ code: d.code, severity: d.severity })),
146
+ },
147
+ null,
148
+ 2,
149
+ )}\n`,
150
+ );
151
+ return 0;
152
+ }
153
+
154
+ default:
155
+ io.err(`edfcore: unknown command ${JSON.stringify(command)}\n\n${USAGE}`);
156
+ return 2;
157
+ }
158
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx edfcore` — looking at a file without writing code.
4
+ *
5
+ * A separate entry point, never re-exported from the barrel, because it imports `node:fs` and
6
+ * `node:process`. The universal entry must stay free of Node built-ins, and a test asserts it.
7
+ *
8
+ * This file is only the wiring. The decisions live in `cli-run.ts`, so they can be tested without
9
+ * spawning a process against a build that may not exist yet.
10
+ *
11
+ * `main()` runs unconditionally rather than behind an `import.meta.url === process.argv[1]` guard.
12
+ * That guard is a known trap for a bin: `npx` runs the command through a symlink, so the two paths
13
+ * differ, the condition is false, and the CLI exits 0 having done nothing.
14
+ */
15
+
16
+ // biome-ignore lint/suspicious/noTsIgnore: @ts-expect-error errors when unused — see node.ts.
17
+ // @ts-ignore 'node:fs/promises' has no declarations under `types: []`; its shape is below.
18
+ import * as nodeFsPromises from 'node:fs/promises';
19
+ // biome-ignore lint/suspicious/noTsIgnore: as above.
20
+ // @ts-ignore 'node:process' has no declarations under `types: []`; its shape is below.
21
+ import * as nodeProcess from 'node:process';
22
+ import { type CliIo, parseArgs, runCli } from './cli-run.js';
23
+ import { isEdfError } from './errors.js';
24
+
25
+ interface NodeFs {
26
+ readFile(path: string): Promise<Uint8Array>;
27
+ }
28
+ interface NodeProcess {
29
+ readonly argv: readonly string[];
30
+ exitCode: number | undefined;
31
+ readonly stdout: { write(text: string): unknown };
32
+ readonly stderr: { write(text: string): unknown };
33
+ }
34
+
35
+ const fs: NodeFs = nodeFsPromises as unknown as NodeFs;
36
+ const proc: NodeProcess =
37
+ (nodeProcess as unknown as { default?: NodeProcess }).default ??
38
+ (nodeProcess as unknown as NodeProcess);
39
+
40
+ const io: CliIo = {
41
+ readFile: (path) => fs.readFile(path),
42
+ out: (text) => {
43
+ proc.stdout.write(text);
44
+ },
45
+ err: (text) => {
46
+ proc.stderr.write(text);
47
+ },
48
+ };
49
+
50
+ async function main(): Promise<void> {
51
+ try {
52
+ proc.exitCode = await runCli(parseArgs(proc.argv.slice(2)), io);
53
+ } catch (error) {
54
+ // An EdfError already says what is wrong, where, and what to do next; a stack trace over the
55
+ // top of it would bury the one useful line.
56
+ const message = isEdfError(error) || error instanceof Error ? error.message : String(error);
57
+ proc.stderr.write(`edfcore: ${message}\n`);
58
+ proc.exitCode = 1;
59
+ }
60
+ }
61
+
62
+ // Not `await main()`: the package documents that no module in its graph uses top-level await,
63
+ // which is what keeps require() safe on Node >= 22.12. main() catches everything, so it cannot
64
+ // reject, and Node keeps the process alive until it settles.
65
+ void main();
package/src/constants.ts CHANGED
@@ -93,4 +93,4 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
93
93
  } as const;
94
94
 
95
95
  /** Published package version. Kept in sync with package.json by a test. */
96
- export const VERSION = '0.1.16';
96
+ export const VERSION = '0.1.18';