edf2csv 0.4.78 → 0.5.13

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/dist/cli.js CHANGED
@@ -45,6 +45,9 @@ Options
45
45
  --annotations-only Write only the EDF+ annotations, no signal data
46
46
  --decimals <n> Fix the decimal places instead of deriving them per channel
47
47
  --checksum Record a SHA-256 of the input in metadata.json
48
+ --layout <kind> wide (default): one column per channel, one file per
49
+ sampling rate. long: one file of time_s,channel,value,
50
+ every rate together, one row per sample
48
51
  --gzip Compress every CSV, writing .csv.gz files
49
52
  --bom Start each CSV with a UTF-8 byte order mark, so Excel
50
53
  reads accented text and units like µV correctly
@@ -54,7 +57,7 @@ Options
54
57
  --json Print machine-readable JSON to stdout (works with --info too)
55
58
  --strict Exit 1 if the recording raised any warning
56
59
  --stdout Write the signal CSV to stdout instead of a directory
57
- (single-rate recordings only)
60
+ (one table only: one sampling rate, or --layout long)
58
61
  -h, --help Show this help
59
62
  -V, --version Show the version
60
63
 
@@ -74,6 +77,9 @@ Output
74
77
  With --gzip each CSV becomes a .csv.gz; metadata.json stays plain text so the
75
78
  directory can still be read at a glance. With --bom each CSV starts with a
76
79
  UTF-8 byte order mark and metadata.json does not, since JSON.parse rejects one.
80
+ With --layout long every channel goes into one signals.csv as time_s, channel
81
+ and value, in time order, whatever rates the recording mixes — which is also
82
+ the one arrangement --stdout can stream for a mixed-rate file.
77
83
 
78
84
  Examples
79
85
  edf2csv recording.edf
@@ -152,6 +158,7 @@ export async function main(argv) {
152
158
  'annotations-only': { type: 'boolean' },
153
159
  decimals: { type: 'string' },
154
160
  checksum: { type: 'boolean' },
161
+ layout: { type: 'string' },
155
162
  gzip: { type: 'boolean' },
156
163
  bom: { type: 'boolean' },
157
164
  jobs: { type: 'string', short: 'j' },
@@ -201,6 +208,24 @@ export async function main(argv) {
201
208
  process.stderr.write(`error: ${printable(entry)}: could not be read, so any recordings inside it were skipped.\n`);
202
209
  }
203
210
  if (expanded.length === 0) {
211
+ /*
212
+ "None here" and "could not look" are different answers, and so are their exit codes.
213
+
214
+ A folder the process cannot read gave the same exit 2 and the same "No EDF or BDF
215
+ recordings found" as an empty one — while the line above it said the folder could not
216
+ be read. Exit 2 is this tool's code for "the command itself was wrong", so a script
217
+ was being told to fix its arguments when what needed fixing was a permission. The
218
+ command was fine; the filesystem refused.
219
+
220
+ And the sentence itself claimed a fact the run is in no position to state: nothing was
221
+ found because nothing was looked at.
222
+ */
223
+ if (unreadable.length > 0) {
224
+ process.stderr.write(`Nothing could be converted: ${unreadable.length === 1 ? 'that path' : 'those paths'} ` +
225
+ `could not be read, so whether ${unreadable.length === 1 ? 'it holds' : 'they hold'} ` +
226
+ `recordings is unknown.\n`);
227
+ return EXIT_ERROR;
228
+ }
204
229
  process.stderr.write(`No EDF or BDF recordings found in ${listed(positionals.map((p) => `"${p}"`))}.\n`);
205
230
  return EXIT_USAGE;
206
231
  }
@@ -228,14 +253,47 @@ export async function main(argv) {
228
253
  'Use --stdout for the CSV, or --json for the summary.\n');
229
254
  return EXIT_USAGE;
230
255
  }
231
- // One stream holds one table, for the same reason it holds one recording: concatenating
232
- // them would give a CSV whose rows come from different files with nothing marking where
233
- // one ends. Naming the count makes it obvious a glob was the cause.
256
+ /*
257
+ One stream holds one table, for the same reason it holds one recording: concatenating
258
+ them would give a CSV whose rows come from different files with nothing marking where
259
+ one ends. Naming the count makes it obvious a glob was the cause — except when the count
260
+ is one, which happens for a folder holding a single recording. That read "--stdout writes
261
+ a single CSV, so it cannot take 1 recordings": ungrammatical, and wrong on its face, since
262
+ one recording is exactly what it can take. What it cannot take is a folder, whose contents
263
+ are not known until they are walked.
264
+ */
234
265
  if (toStdout && batch) {
235
- process.stderr.write(`--stdout writes a single CSV, so it cannot take ${inputs.length} recordings.\n` +
236
- `Convert them to directories instead, or run edf2csv once per file.\n`);
266
+ process.stderr.write(inputs.length === 1
267
+ ? `--stdout writes a single CSV, and a folder is converted as a batch even when it ` +
268
+ `holds one recording.\nName the recording itself — ${inputs[0]} — or convert to a ` +
269
+ `directory instead.\n`
270
+ : `--stdout writes a single CSV, so it cannot take ${inputs.length} recordings.\n` +
271
+ `Convert them to directories instead, or run edf2csv once per file.\n`);
237
272
  return EXIT_USAGE;
238
273
  }
274
+ /*
275
+ Flags that --stdout has nowhere to put.
276
+
277
+ Both were accepted and dropped in silence. `--out` names a directory that is never
278
+ created, so the run looked like it had written one. `--checksum` is worse than useless:
279
+ the hash is computed before the first record is read, which is a second full pass over
280
+ the input, and then the only file it is ever written to — metadata.json — is not written
281
+ at all. A recording large enough to want a checksum is large enough to notice reading it
282
+ twice for nothing.
283
+
284
+ Refusing rather than ignoring is what this tool already does for `--stdout --json` and
285
+ `--stdout --annotations-only`.
286
+ */
287
+ for (const [flag, given] of [
288
+ ['--out', values['out'] !== undefined],
289
+ ['--checksum', values['checksum'] === true],
290
+ ]) {
291
+ if (toStdout && given) {
292
+ process.stderr.write(`--stdout and ${flag} cannot be combined: --stdout writes no files, and ${flag} has ` +
293
+ `nothing to act on.\nDrop ${flag}, or drop --stdout and convert to a directory.\n`);
294
+ return EXIT_USAGE;
295
+ }
296
+ }
239
297
  /*
240
298
  One recording prints the document it always printed; several print one per line.
241
299
 
@@ -264,6 +322,7 @@ export async function main(argv) {
264
322
  annotationsOnly: values['annotations-only'] === true,
265
323
  gzip: values['gzip'] === true,
266
324
  bom: values['bom'] === true,
325
+ layout: optionalLayout(values['layout']),
267
326
  };
268
327
  // Validated before the --info branch, not inside the conversion path: a flag that
269
328
  // cannot be honoured is a usage error whatever mode it was given in, and accepting
@@ -573,7 +632,18 @@ async function convertOne(input, destination, options, emit = writeThrough) {
573
632
  // thing worth saying — on stderr, so the CSV on stdout stays clean.
574
633
  if (toStdout) {
575
634
  const rows = result.files[0]?.rows ?? 0;
576
- emit('err', `Wrote ${rows.toLocaleString('en-US')} rows to stdout.\n`);
635
+ /*
636
+ A reader that closed the pipe did not receive a conversion, so it does not get a
637
+ conversion's summary. `edf2csv rec.edf --stdout | head -1` announced "Wrote 52,507
638
+ rows to stdout" — a number that is neither the recording's 102,400 nor the one row
639
+ head took, but however many had been formatted before the close was noticed. The
640
+ count that reached the reader is not knowable from this side; that it stopped early
641
+ is, so that is what is said.
642
+ */
643
+ emit('err', result.readerHungUp
644
+ ? `Stopped: the reader closed the pipe after ${rows.toLocaleString('en-US')} rows ` +
645
+ `had been written. The recording was not converted in full.\n`
646
+ : `Wrote ${rows.toLocaleString('en-US')} rows to stdout.\n`);
577
647
  }
578
648
  else {
579
649
  emit('err', `${formatSummary(result)}\n`);
@@ -974,7 +1044,7 @@ async function convertInChild(input, destination, values, running) {
974
1044
  if (values[flag] === true)
975
1045
  args.push(`--${flag}`);
976
1046
  }
977
- for (const flag of ['start', 'duration', 'end', 'decimals']) {
1047
+ for (const flag of ['start', 'duration', 'end', 'decimals', 'layout']) {
978
1048
  if (typeof values[flag] === 'string')
979
1049
  args.push(`--${flag}=${values[flag]}`);
980
1050
  }
@@ -1132,6 +1202,14 @@ function optionalTime(raw, option) {
1132
1202
  return undefined;
1133
1203
  return parseTimeSpec(String(raw), option);
1134
1204
  }
1205
+ /** `--layout`, which is one of two words and not a guess at what was meant. */
1206
+ function optionalLayout(raw) {
1207
+ if (raw === undefined)
1208
+ return undefined;
1209
+ if (raw === 'wide' || raw === 'long')
1210
+ return raw;
1211
+ throw new OptionError(`--layout must be "wide" or "long", got "${String(raw)}".`);
1212
+ }
1135
1213
  function optionalDecimals(raw) {
1136
1214
  if (raw === undefined)
1137
1215
  return undefined;