edf2csv 0.8.87 → 0.8.88

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.
@@ -224,11 +224,44 @@ export function assertSignals(signals) {
224
224
  if (!Array.isArray(signals)) {
225
225
  throw new OptionError(`signals must be the channel list from a header, got ${describeValue(signals)}.`);
226
226
  }
227
- const wrong = signals.findIndex((signal) => typeof signal !== 'object' ||
228
- signal === null ||
229
- typeof signal.index !== 'number');
230
- if (wrong !== -1) {
231
- throw new OptionError(`signals[${wrong}] is not a channel from a header, got ${describeValue(signals[wrong])}.`);
227
+ /*
228
+ The fields the callers read, not just the one that identifies a channel.
229
+
230
+ This asked for `index` and stopped, and its callers go on to read `label` and
231
+ `isAnnotations` off the same objects. So a list of channel-shaped objects without them
232
+ passed the check that exists to say "these are not channels from a header" and failed one
233
+ line further in, or did not fail at all:
234
+
235
+ selectChannels([{ index: 0 }], ['ECG'])
236
+ TypeError: Cannot read properties of undefined (reading 'toLowerCase')
237
+
238
+ selectChannels([{ index: 0, label: 42 }], ['42'])
239
+ TypeError: signal.label.toLowerCase is not a function
240
+
241
+ buildColumnNames([{ index: 0 }])
242
+ Map { 0 => null }
243
+
244
+ The first two are the failure this function was written to remove, one level down — a
245
+ local of this package named at a caller who wrote neither. The third is worse: a column
246
+ name of `null`, where a channel with no label at all is named `signal_0`, so `null` is not
247
+ a name this tool ever writes.
248
+ */
249
+ const FIELDS = [
250
+ ['index', 'number'],
251
+ ['label', 'string'],
252
+ ['isAnnotations', 'boolean'],
253
+ ];
254
+ for (const [at, signal] of signals.entries()) {
255
+ if (typeof signal !== 'object' || signal === null) {
256
+ throw new OptionError(`signals[${at}] is not a channel from a header, got ${describeValue(signal)}.`);
257
+ }
258
+ const fields = signal;
259
+ const wrong = FIELDS.find(([name, kind]) => typeof fields[name] !== kind);
260
+ if (wrong === undefined)
261
+ continue;
262
+ const [name, kind] = wrong;
263
+ throw new OptionError(`signals[${at}].${name} must be a ${kind}, got ${describeValue(fields[name])}. A channel ` +
264
+ `from a header carries ${FIELDS.map(([f]) => f).join(', ')}.`);
232
265
  }
233
266
  }
234
267
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/convert/options.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Checking the options a caller passed, before anything is written.\n *\n * The command line has always validated these — `--decimals 1.5` is a usage error and always\n * has been — and the library did not, so the same value behaved differently depending on how\n * it arrived. `convert(file, { decimals: NaN })` resolved successfully having written whole\n * numbers into a column the caller had asked for decimals in, which is the worst of the\n * three: no error, no warning, and output that looks like a deliberate choice. `decimals: -1`\n * reached `toFixed` and came back as a bare RangeError from deep inside the formatter, naming\n * nothing the caller had written. `start: NaN` created the output directory, wrote\n * signals.csv, and then failed with a message about the input being unreadable — a partial\n * conversion, blamed on the file.\n *\n * These run at the top of `buildPlan`, which every path goes through before a directory is\n * created or a stream is opened, so a rejected option leaves nothing behind.\n */\n\n/** A problem with the options a caller passed, as opposed to a problem with the file. */\nexport class OptionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'OptionError';\n }\n}\n\n/**\n * The largest `--decimals` accepts, and what both documentation pages state.\n *\n * Not a limit of `toFixed`, whatever this comment used to say. `toFixed` takes 0 to 100 and\n * throws a RangeError at 101 — which is exactly the belief `MAX_DERIVED_DECIMALS` in\n * edf/scale.ts exists to correct, having once clamped the *derived* precision to 20 on the\n * same wrong grounds and rounded a magnetometer channel needing 23 places onto a grid three\n * digital codes wide, losing 69% of its samples in silence.\n *\n * Twenty is a bound on a number a person types by hand, not on what the format can express.\n * The derived precision, which nobody types, runs to 100 and says so.\n */\nexport const MAX_DECIMALS = 20;\n\nexport function assertOptions(options: {\n decimals?: number | undefined;\n start?: number | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n layout?: string | undefined;\n channels?: readonly string[] | undefined;\n outputDir?: string | undefined;\n annotationsOnly?: boolean | undefined;\n gzip?: boolean | undefined;\n bom?: boolean | undefined;\n force?: boolean | undefined;\n checksum?: boolean | undefined;\n toStdout?: boolean | undefined;\n onProgress?: unknown;\n}): void {\n const { decimals } = options;\n if (decimals !== undefined) {\n if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {\n throw new OptionError(\n `decimals must be a whole number between 0 and ${MAX_DECIMALS}, got ${describeValue(decimals)}.`,\n );\n }\n }\n\n /*\n `start` and `end` are positions on the recording's own clock, `duration` is a length.\n\n All three were held above zero, which is right for a length and wrong for a position: a\n recording timed from its first record's timekeeping annotation may sit before zero, and\n -100 is then where its first sample is. So a caller could read `plan.range` back as\n `recordingStartSeconds: -100` and not be allowed to ask for it — the same wall\n `parseTimeSpec` put in front of the command line until 0.5.120.\n\n Non-finite is still refused for all three, since NaN reaches a comparison as false and\n would take the whole recording without saying so.\n */\n for (const name of ['start', 'duration', 'end'] as const) {\n const value = options[name];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw new OptionError(`${name} must be a number of seconds, got ${describeValue(value)}.`);\n }\n /*\n A length below zero is refused for what it is, rather than for not being a number.\n\n `duration: -1` came back as \"duration must be a number of seconds, got -1\", which is\n not the reason and not true: -1 is a number of seconds, and this same call accepts it\n for `start` and for `end`, where a recording timed from before zero makes it an\n ordinary position. What is wrong is that a duration is a length, and no length is\n negative — which is what the command line says for the same value, and what the API\n reference has always said this check enforces.\n */\n if (name === 'duration' && value < 0) {\n throw new OptionError(`duration is a length of time, so it cannot be ${describeValue(value)}.`);\n }\n }\n\n /*\n Two words, and a caller who writes a third means something the tool cannot do.\n\n The command line has always rejected `--layout tall`. The library took it, put it in\n `plan.layout` for the caller to read back, and wrote the wide layout — so a programmatic\n caller with a typo got a conversion that was not the one they asked for, described by a\n plan that agreed with the typo. Every other option that has a shape is checked here; this\n one was added in 0.5.0 and never joined them.\n */\n const { layout } = options;\n if (layout !== undefined && layout !== 'wide' && layout !== 'long') {\n throw new OptionError(`layout must be \"wide\" or \"long\", got ${describeValue(layout)}.`);\n }\n\n /*\n A selection that names nothing is not the absence of a selection.\n\n `buildPlan` asks `options.channels.length > 0` before selecting, so an empty array fell\n through to the branch that means \"no channels option was given\" — and `convert(file, {\n channels: [] })` wrote every channel in the recording, resolved, and said nothing. That is\n the one shape of this the command line has always refused, in as many words: \"Returning\n undefined here would mean 'no --channels given' and convert everything, which is the\n opposite of what someone passing an empty list is asking for.\"\n\n A list of blanks is the same request written differently — it is what `''.split(',')`\n produces, which is how a caller building the array from user input arrives here — and it\n reached `selectChannels` and came back \"No channels were selected\", a sentence about the\n file rather than about the call. Both are the option being wrong, so both are refused\n here, before a directory exists.\n */\n /*\n An empty destination, refused here rather than by the filesystem.\n\n `convert(file, { outputDir: '' })` went the whole way to `mkdir('')` and came back a\n `ConversionError`: \"Cannot create \\\"\\\": part of the path does not exist. Check the path\n exists and that you have permission to write there.\" Advice about a path and a permission\n for a value that is neither, and a failure class that means the conversion went wrong\n where the option did.\n\n The command line refused this at 0.6.x, with the reasoning that `--out \"$DEST\"` and `DEST`\n unset is how it gets written by accident — and left the library, which a caller building\n the path in code reaches the same way. Not trimmed, for the reason given there: a\n directory whose name is a space is a strange thing to ask for, but it is a thing the\n filesystem has and a path is not a keyword.\n */\n /*\n And its shape, which was the one option with a value and no check on it.\n\n The empty string was refused and nothing else was, so `outputDir` failed two ways that the\n paragraph above describes for the flags. A value of the wrong type reached `path.join` and\n came back as a Node error about an argument this caller never passed:\n\n convert('rec.edf', { outputDir: 42 })\n TypeError: The \"path\" argument must be of type string. Received type number (42)\n\n And `null` — which is what `JSON.parse` of a config file gives for a field left unset, the\n same door `1` and `'true'` come through — was not an error at all. It is not `undefined`,\n so it never meant \"use the default\", but every read of it is `?? default` or a truthiness\n test, so that is what it did: the rows went to `<recording>_csv` beside the input, a\n directory the caller had not named, and the run reported success.\n */\n const { outputDir } = options;\n if (outputDir !== undefined && typeof outputDir !== 'string') {\n throw new OptionError(`outputDir must be a path, got ${describeValue(outputDir)}.`);\n }\n if (outputDir === '') {\n throw new OptionError('outputDir is empty. Give a directory, for example \"./converted\".');\n }\n\n /*\n The six flags, every one of which is read as `=== true` where it is read.\n\n Which means a value that is not a boolean is not merely tolerated: it is taken as the\n opposite of what it says. `convert(file, { annotationsOnly: 'true' })` wrote every signal\n the caller had asked to leave out; `{ gzip: 1 }` wrote plain CSVs under names ending\n `.csv`, so a caller who then opened `signals.csv.gz` found nothing there. No error, no\n warning, and output that looks like a deliberate choice — which is the sentence at the\n top of this file, describing the case it was written for.\n\n `1` and `'true'` are how a flag arrives from `JSON.parse` of a config file, from a query\n string, or from a CLI wrapper that did not coerce; none of them is a caller being\n careless in a way TypeScript would catch, since the callers this reaches are the ones not\n using it. The layout check above states the rule these were missing from: \"Every other\n option that has a shape is checked here.\"\n */\n for (const name of ['annotationsOnly', 'gzip', 'bom', 'force', 'checksum', 'toStdout'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'boolean') {\n throw new OptionError(`${name} must be true or false, got ${describeValue(value)}.`);\n }\n }\n\n /*\n The one option that is called rather than read, and the only one that was not checked.\n\n `convert` invokes it as `options.onProgress?.(...)` once a record has been written, so a\n value that is not a function passes every check here, opens the destination, writes rows\n into it and then fails from inside the loop:\n\n convert('rec.edf', { outputDir: 'out', onProgress: 'every record' })\n ConversionError: The onProgress callback threw: options.onProgress is not a function\n\n A callback that threw is what that sentence reports, and no callback was given; the text\n after the colon names an expression inside this package. Worse is what it leaves: `out`\n exists with a half-written signals.csv in it, which is the case the paragraph at the top\n of this file describes — \"`start: NaN` created the output directory, wrote signals.csv,\n and then failed with a message about the input being unreadable — a partial conversion,\n blamed on the file\". Checked here, the same call writes nothing and says which argument\n is wrong.\n */\n const { onProgress } = options;\n if (onProgress !== undefined && typeof onProgress !== 'function') {\n throw new OptionError(`onProgress must be a function, got ${describeValue(onProgress)}.`);\n }\n\n const { channels } = options;\n if (channels !== undefined) {\n /*\n A list of strings, checked as one. `selectChannels` calls `.trim()` on every term, so a\n caller who passed the string `'ECG'` had it iterated character by character and was told\n `No channel named \"E\"`, and one who passed `[1]` — a position, reasonably enough — got\n `TypeError: rawTerm.trim is not a function` out of the middle of the selector, naming\n nothing they had written. Both are the option being the wrong shape, which is the case\n this function exists for.\n */\n if (!Array.isArray(channels) || channels.some((term) => typeof term !== 'string')) {\n throw new OptionError(`channels must be a list of channel names, got ${describeValue(channels)}.`);\n }\n if (channels.every((term) => term.trim() === '')) {\n throw new OptionError('channels was given but lists no channel names.');\n }\n }\n}\n\n/**\n * The channel list two exported functions take, checked the way their other argument is.\n *\n * `selectChannels(signals, terms)` has checked `terms` since 0.6.x — \"`'ECG'` was iterated\n * character by character and answered `No channel named \\\"E\\\"`… naming nothing the caller had\n * written\" — and never checked `signals`, which is the argument in front of it. Passing one\n * signal where the list goes, or a header where its `signals` goes, came back as\n * `TypeError: signals.filter is not a function`: a local of this package, over a value the\n * caller did write.\n *\n * `buildColumnNames` is worse off, because a string is iterable. `buildColumnNames('ECG')`\n * returned `Map { null => 'undefined_chundefined' }` and no error at all — a column name for a\n * channel that does not exist, keyed by a position that is not one.\n *\n * The bad entry is named by position rather than the whole list being printed back: a header\n * may declare hundreds of channels, and a message is not the place for all of them.\n */\nexport function assertSignals(signals: unknown): void {\n if (!Array.isArray(signals)) {\n throw new OptionError(\n `signals must be the channel list from a header, got ${describeValue(signals)}.`,\n );\n }\n const wrong = signals.findIndex(\n (signal) =>\n typeof signal !== 'object' ||\n signal === null ||\n typeof (signal as { index?: unknown }).index !== 'number',\n );\n if (wrong !== -1) {\n throw new OptionError(\n `signals[${wrong}] is not a channel from a header, got ${describeValue(signals[wrong])}.`,\n );\n }\n}\n\n/**\n * What `buildPlan` is told about the recording, checked the way what it is asked for is.\n *\n * `assertOptions` runs at the top of `buildPlan` and covers the second argument completely.\n * The first was not looked at, and it is the one carrying the numbers every figure in the plan\n * is derived from. Two of them missing produced a plan rather than an error:\n *\n * buildPlan({ signals, recordDuration: 1 }, {})\n * // groups: 3, estimate.rows: 0, range.endSeconds: null\n *\n * A plan saying the conversion writes nothing, handed back as an answer — which is the \"takes\n * the whole recording without saying so\" this checker exists to stop, one field over. A record\n * count below zero was worse: it came back as\n *\n * TimeRangeError: --start 0s is at or past the end of this -5s recording.\n *\n * a flag the caller never passed, about a recording that cannot exist, blaming the request for\n * the input. And `recordDuration: '1'` was coerced by the arithmetic and accepted, where the\n * same string is refused for `end` two functions down.\n *\n * A real header cannot produce any of them: the parser refuses a record duration that is not a\n * positive number, \"Infinity\" included.\n */\nexport function assertPlanInput(input: {\n signals?: unknown;\n recordDuration?: unknown;\n recordCount?: unknown;\n}): void {\n assertSignals(input.signals);\n assertRecordShape(input);\n}\n\n/**\n * The two numbers a window is measured against, apart from the channel list.\n *\n * `resolveRange` is exported on its own and has its own signature block on the api page, and\n * `buildPlan` calls it — so it was covered only from above. Called directly it took both\n * numbers unexamined and answered with a range:\n *\n * resolveRange({ recordDuration: 1 }) // recordCount undefined\n * // { startSeconds: 0, endSeconds: null, startRecord: 0, endRecord: 0 }\n *\n * A range over no records, returned as a fact about a recording. Its own opening comment\n * already says why that is the wrong answer — \"no error, no warning, and a range read back as\n * `startSeconds: null, startRecord: null`, which is the 'takes the whole recording without\n * saying so'\" — about the three fields it does check. `resolveRange(42)` went the same way,\n * since reading `.start` off a number is `undefined` rather than a throw.\n *\n * Split out rather than calling `assertPlanInput`, which would demand a channel list this\n * function never looks at.\n */\nexport function assertRecordShape(input: {\n recordDuration?: unknown;\n recordCount?: unknown;\n}): void {\n const { recordDuration, recordCount } = input ?? {};\n if (typeof recordDuration !== 'number' || !Number.isFinite(recordDuration) || recordDuration <= 0) {\n throw new OptionError(\n `recordDuration must be a positive number of seconds, got ${describeValue(recordDuration)}.`,\n );\n }\n if (!Number.isInteger(recordCount) || (recordCount as number) < 0) {\n throw new OptionError(\n `recordCount must be a whole number of data records, got ${describeValue(recordCount)}.`,\n );\n }\n}\n\n/**\n * The recording to read, checked before it is opened.\n *\n * `EdfFile.open` hands whatever it is given to `fs`, and the refusal comes back as an\n * `EdfError` coded `UNREADABLE`, hinted \"Check the path is spelled the way it is on disk and\n * that you have permission to read it\" — advice about a path, over a value that is not one,\n * filed as a problem with the recording rather than with the call. `convert({ input: 'a.edf' })`,\n * which is the option-bag shape the second parameter has, answered `Cannot read \"[object\n * Object]\"`; `convert(['a.edf', 'b.edf'])` answered `Cannot read \"a.edf,b.edf\"`, a path the\n * caller never wrote, because `String` of an array joins it with commas.\n *\n * The empty string is left to `fs`, which has no such file and says so truthfully — the same\n * reasoning `outputDir` states for not trimming: a path is not a keyword.\n */\nexport function assertInputPath(input: unknown): void {\n if (typeof input !== 'string') {\n throw new OptionError(`input must be a path to a recording, got ${describeValue(input)}.`);\n }\n}\n\n/**\n * How a rejected value reads in the refusal: numbers bare, everything else quoted so its\n * type is visible.\n *\n * `JSON.stringify` has no text for a function or a symbol — it returns `undefined`, not a\n * string — so `layout: () => 'long'` came back as `layout must be \"wide\" or \"long\", got\n * undefined.`, which names the one value that does not raise this: every option here is\n * optional, and `undefined` is how a caller says they are not passing it. `input` was worse,\n * since `convert(undefined)` and `convert(someFunction)` then produced the same sentence, and\n * the first is a forgotten argument while the second is a wrong one.\n *\n * Exported because time-range.ts had the identical function, fixed there and not here — the\n * same two-copies-of-one-helper the derived precision and the pluraliser were each pulled\n * together for.\n */\nexport function describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);\n}\n"]}
1
+ {"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/convert/options.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Checking the options a caller passed, before anything is written.\n *\n * The command line has always validated these — `--decimals 1.5` is a usage error and always\n * has been — and the library did not, so the same value behaved differently depending on how\n * it arrived. `convert(file, { decimals: NaN })` resolved successfully having written whole\n * numbers into a column the caller had asked for decimals in, which is the worst of the\n * three: no error, no warning, and output that looks like a deliberate choice. `decimals: -1`\n * reached `toFixed` and came back as a bare RangeError from deep inside the formatter, naming\n * nothing the caller had written. `start: NaN` created the output directory, wrote\n * signals.csv, and then failed with a message about the input being unreadable — a partial\n * conversion, blamed on the file.\n *\n * These run at the top of `buildPlan`, which every path goes through before a directory is\n * created or a stream is opened, so a rejected option leaves nothing behind.\n */\n\n/** A problem with the options a caller passed, as opposed to a problem with the file. */\nexport class OptionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'OptionError';\n }\n}\n\n/**\n * The largest `--decimals` accepts, and what both documentation pages state.\n *\n * Not a limit of `toFixed`, whatever this comment used to say. `toFixed` takes 0 to 100 and\n * throws a RangeError at 101 — which is exactly the belief `MAX_DERIVED_DECIMALS` in\n * edf/scale.ts exists to correct, having once clamped the *derived* precision to 20 on the\n * same wrong grounds and rounded a magnetometer channel needing 23 places onto a grid three\n * digital codes wide, losing 69% of its samples in silence.\n *\n * Twenty is a bound on a number a person types by hand, not on what the format can express.\n * The derived precision, which nobody types, runs to 100 and says so.\n */\nexport const MAX_DECIMALS = 20;\n\nexport function assertOptions(options: {\n decimals?: number | undefined;\n start?: number | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n layout?: string | undefined;\n channels?: readonly string[] | undefined;\n outputDir?: string | undefined;\n annotationsOnly?: boolean | undefined;\n gzip?: boolean | undefined;\n bom?: boolean | undefined;\n force?: boolean | undefined;\n checksum?: boolean | undefined;\n toStdout?: boolean | undefined;\n onProgress?: unknown;\n}): void {\n const { decimals } = options;\n if (decimals !== undefined) {\n if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {\n throw new OptionError(\n `decimals must be a whole number between 0 and ${MAX_DECIMALS}, got ${describeValue(decimals)}.`,\n );\n }\n }\n\n /*\n `start` and `end` are positions on the recording's own clock, `duration` is a length.\n\n All three were held above zero, which is right for a length and wrong for a position: a\n recording timed from its first record's timekeeping annotation may sit before zero, and\n -100 is then where its first sample is. So a caller could read `plan.range` back as\n `recordingStartSeconds: -100` and not be allowed to ask for it — the same wall\n `parseTimeSpec` put in front of the command line until 0.5.120.\n\n Non-finite is still refused for all three, since NaN reaches a comparison as false and\n would take the whole recording without saying so.\n */\n for (const name of ['start', 'duration', 'end'] as const) {\n const value = options[name];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw new OptionError(`${name} must be a number of seconds, got ${describeValue(value)}.`);\n }\n /*\n A length below zero is refused for what it is, rather than for not being a number.\n\n `duration: -1` came back as \"duration must be a number of seconds, got -1\", which is\n not the reason and not true: -1 is a number of seconds, and this same call accepts it\n for `start` and for `end`, where a recording timed from before zero makes it an\n ordinary position. What is wrong is that a duration is a length, and no length is\n negative — which is what the command line says for the same value, and what the API\n reference has always said this check enforces.\n */\n if (name === 'duration' && value < 0) {\n throw new OptionError(`duration is a length of time, so it cannot be ${describeValue(value)}.`);\n }\n }\n\n /*\n Two words, and a caller who writes a third means something the tool cannot do.\n\n The command line has always rejected `--layout tall`. The library took it, put it in\n `plan.layout` for the caller to read back, and wrote the wide layout — so a programmatic\n caller with a typo got a conversion that was not the one they asked for, described by a\n plan that agreed with the typo. Every other option that has a shape is checked here; this\n one was added in 0.5.0 and never joined them.\n */\n const { layout } = options;\n if (layout !== undefined && layout !== 'wide' && layout !== 'long') {\n throw new OptionError(`layout must be \"wide\" or \"long\", got ${describeValue(layout)}.`);\n }\n\n /*\n A selection that names nothing is not the absence of a selection.\n\n `buildPlan` asks `options.channels.length > 0` before selecting, so an empty array fell\n through to the branch that means \"no channels option was given\" — and `convert(file, {\n channels: [] })` wrote every channel in the recording, resolved, and said nothing. That is\n the one shape of this the command line has always refused, in as many words: \"Returning\n undefined here would mean 'no --channels given' and convert everything, which is the\n opposite of what someone passing an empty list is asking for.\"\n\n A list of blanks is the same request written differently — it is what `''.split(',')`\n produces, which is how a caller building the array from user input arrives here — and it\n reached `selectChannels` and came back \"No channels were selected\", a sentence about the\n file rather than about the call. Both are the option being wrong, so both are refused\n here, before a directory exists.\n */\n /*\n An empty destination, refused here rather than by the filesystem.\n\n `convert(file, { outputDir: '' })` went the whole way to `mkdir('')` and came back a\n `ConversionError`: \"Cannot create \\\"\\\": part of the path does not exist. Check the path\n exists and that you have permission to write there.\" Advice about a path and a permission\n for a value that is neither, and a failure class that means the conversion went wrong\n where the option did.\n\n The command line refused this at 0.6.x, with the reasoning that `--out \"$DEST\"` and `DEST`\n unset is how it gets written by accident — and left the library, which a caller building\n the path in code reaches the same way. Not trimmed, for the reason given there: a\n directory whose name is a space is a strange thing to ask for, but it is a thing the\n filesystem has and a path is not a keyword.\n */\n /*\n And its shape, which was the one option with a value and no check on it.\n\n The empty string was refused and nothing else was, so `outputDir` failed two ways that the\n paragraph above describes for the flags. A value of the wrong type reached `path.join` and\n came back as a Node error about an argument this caller never passed:\n\n convert('rec.edf', { outputDir: 42 })\n TypeError: The \"path\" argument must be of type string. Received type number (42)\n\n And `null` — which is what `JSON.parse` of a config file gives for a field left unset, the\n same door `1` and `'true'` come through — was not an error at all. It is not `undefined`,\n so it never meant \"use the default\", but every read of it is `?? default` or a truthiness\n test, so that is what it did: the rows went to `<recording>_csv` beside the input, a\n directory the caller had not named, and the run reported success.\n */\n const { outputDir } = options;\n if (outputDir !== undefined && typeof outputDir !== 'string') {\n throw new OptionError(`outputDir must be a path, got ${describeValue(outputDir)}.`);\n }\n if (outputDir === '') {\n throw new OptionError('outputDir is empty. Give a directory, for example \"./converted\".');\n }\n\n /*\n The six flags, every one of which is read as `=== true` where it is read.\n\n Which means a value that is not a boolean is not merely tolerated: it is taken as the\n opposite of what it says. `convert(file, { annotationsOnly: 'true' })` wrote every signal\n the caller had asked to leave out; `{ gzip: 1 }` wrote plain CSVs under names ending\n `.csv`, so a caller who then opened `signals.csv.gz` found nothing there. No error, no\n warning, and output that looks like a deliberate choice — which is the sentence at the\n top of this file, describing the case it was written for.\n\n `1` and `'true'` are how a flag arrives from `JSON.parse` of a config file, from a query\n string, or from a CLI wrapper that did not coerce; none of them is a caller being\n careless in a way TypeScript would catch, since the callers this reaches are the ones not\n using it. The layout check above states the rule these were missing from: \"Every other\n option that has a shape is checked here.\"\n */\n for (const name of ['annotationsOnly', 'gzip', 'bom', 'force', 'checksum', 'toStdout'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'boolean') {\n throw new OptionError(`${name} must be true or false, got ${describeValue(value)}.`);\n }\n }\n\n /*\n The one option that is called rather than read, and the only one that was not checked.\n\n `convert` invokes it as `options.onProgress?.(...)` once a record has been written, so a\n value that is not a function passes every check here, opens the destination, writes rows\n into it and then fails from inside the loop:\n\n convert('rec.edf', { outputDir: 'out', onProgress: 'every record' })\n ConversionError: The onProgress callback threw: options.onProgress is not a function\n\n A callback that threw is what that sentence reports, and no callback was given; the text\n after the colon names an expression inside this package. Worse is what it leaves: `out`\n exists with a half-written signals.csv in it, which is the case the paragraph at the top\n of this file describes — \"`start: NaN` created the output directory, wrote signals.csv,\n and then failed with a message about the input being unreadable — a partial conversion,\n blamed on the file\". Checked here, the same call writes nothing and says which argument\n is wrong.\n */\n const { onProgress } = options;\n if (onProgress !== undefined && typeof onProgress !== 'function') {\n throw new OptionError(`onProgress must be a function, got ${describeValue(onProgress)}.`);\n }\n\n const { channels } = options;\n if (channels !== undefined) {\n /*\n A list of strings, checked as one. `selectChannels` calls `.trim()` on every term, so a\n caller who passed the string `'ECG'` had it iterated character by character and was told\n `No channel named \"E\"`, and one who passed `[1]` — a position, reasonably enough — got\n `TypeError: rawTerm.trim is not a function` out of the middle of the selector, naming\n nothing they had written. Both are the option being the wrong shape, which is the case\n this function exists for.\n */\n if (!Array.isArray(channels) || channels.some((term) => typeof term !== 'string')) {\n throw new OptionError(`channels must be a list of channel names, got ${describeValue(channels)}.`);\n }\n if (channels.every((term) => term.trim() === '')) {\n throw new OptionError('channels was given but lists no channel names.');\n }\n }\n}\n\n/**\n * The channel list two exported functions take, checked the way their other argument is.\n *\n * `selectChannels(signals, terms)` has checked `terms` since 0.6.x — \"`'ECG'` was iterated\n * character by character and answered `No channel named \\\"E\\\"`… naming nothing the caller had\n * written\" — and never checked `signals`, which is the argument in front of it. Passing one\n * signal where the list goes, or a header where its `signals` goes, came back as\n * `TypeError: signals.filter is not a function`: a local of this package, over a value the\n * caller did write.\n *\n * `buildColumnNames` is worse off, because a string is iterable. `buildColumnNames('ECG')`\n * returned `Map { null => 'undefined_chundefined' }` and no error at all — a column name for a\n * channel that does not exist, keyed by a position that is not one.\n *\n * The bad entry is named by position rather than the whole list being printed back: a header\n * may declare hundreds of channels, and a message is not the place for all of them.\n */\nexport function assertSignals(signals: unknown): void {\n if (!Array.isArray(signals)) {\n throw new OptionError(\n `signals must be the channel list from a header, got ${describeValue(signals)}.`,\n );\n }\n /*\n The fields the callers read, not just the one that identifies a channel.\n\n This asked for `index` and stopped, and its callers go on to read `label` and\n `isAnnotations` off the same objects. So a list of channel-shaped objects without them\n passed the check that exists to say \"these are not channels from a header\" and failed one\n line further in, or did not fail at all:\n\n selectChannels([{ index: 0 }], ['ECG'])\n TypeError: Cannot read properties of undefined (reading 'toLowerCase')\n\n selectChannels([{ index: 0, label: 42 }], ['42'])\n TypeError: signal.label.toLowerCase is not a function\n\n buildColumnNames([{ index: 0 }])\n Map { 0 => null }\n\n The first two are the failure this function was written to remove, one level down — a\n local of this package named at a caller who wrote neither. The third is worse: a column\n name of `null`, where a channel with no label at all is named `signal_0`, so `null` is not\n a name this tool ever writes.\n */\n const FIELDS = [\n ['index', 'number'],\n ['label', 'string'],\n ['isAnnotations', 'boolean'],\n ] as const;\n for (const [at, signal] of signals.entries()) {\n if (typeof signal !== 'object' || signal === null) {\n throw new OptionError(\n `signals[${at}] is not a channel from a header, got ${describeValue(signal)}.`,\n );\n }\n const fields = signal as Record<string, unknown>;\n const wrong = FIELDS.find(([name, kind]) => typeof fields[name] !== kind);\n if (wrong === undefined) continue;\n const [name, kind] = wrong;\n throw new OptionError(\n `signals[${at}].${name} must be a ${kind}, got ${describeValue(fields[name])}. A channel ` +\n `from a header carries ${FIELDS.map(([f]) => f).join(', ')}.`,\n );\n }\n}\n\n/**\n * What `buildPlan` is told about the recording, checked the way what it is asked for is.\n *\n * `assertOptions` runs at the top of `buildPlan` and covers the second argument completely.\n * The first was not looked at, and it is the one carrying the numbers every figure in the plan\n * is derived from. Two of them missing produced a plan rather than an error:\n *\n * buildPlan({ signals, recordDuration: 1 }, {})\n * // groups: 3, estimate.rows: 0, range.endSeconds: null\n *\n * A plan saying the conversion writes nothing, handed back as an answer — which is the \"takes\n * the whole recording without saying so\" this checker exists to stop, one field over. A record\n * count below zero was worse: it came back as\n *\n * TimeRangeError: --start 0s is at or past the end of this -5s recording.\n *\n * a flag the caller never passed, about a recording that cannot exist, blaming the request for\n * the input. And `recordDuration: '1'` was coerced by the arithmetic and accepted, where the\n * same string is refused for `end` two functions down.\n *\n * A real header cannot produce any of them: the parser refuses a record duration that is not a\n * positive number, \"Infinity\" included.\n */\nexport function assertPlanInput(input: {\n signals?: unknown;\n recordDuration?: unknown;\n recordCount?: unknown;\n}): void {\n assertSignals(input.signals);\n assertRecordShape(input);\n}\n\n/**\n * The two numbers a window is measured against, apart from the channel list.\n *\n * `resolveRange` is exported on its own and has its own signature block on the api page, and\n * `buildPlan` calls it — so it was covered only from above. Called directly it took both\n * numbers unexamined and answered with a range:\n *\n * resolveRange({ recordDuration: 1 }) // recordCount undefined\n * // { startSeconds: 0, endSeconds: null, startRecord: 0, endRecord: 0 }\n *\n * A range over no records, returned as a fact about a recording. Its own opening comment\n * already says why that is the wrong answer — \"no error, no warning, and a range read back as\n * `startSeconds: null, startRecord: null`, which is the 'takes the whole recording without\n * saying so'\" — about the three fields it does check. `resolveRange(42)` went the same way,\n * since reading `.start` off a number is `undefined` rather than a throw.\n *\n * Split out rather than calling `assertPlanInput`, which would demand a channel list this\n * function never looks at.\n */\nexport function assertRecordShape(input: {\n recordDuration?: unknown;\n recordCount?: unknown;\n}): void {\n const { recordDuration, recordCount } = input ?? {};\n if (typeof recordDuration !== 'number' || !Number.isFinite(recordDuration) || recordDuration <= 0) {\n throw new OptionError(\n `recordDuration must be a positive number of seconds, got ${describeValue(recordDuration)}.`,\n );\n }\n if (!Number.isInteger(recordCount) || (recordCount as number) < 0) {\n throw new OptionError(\n `recordCount must be a whole number of data records, got ${describeValue(recordCount)}.`,\n );\n }\n}\n\n/**\n * The recording to read, checked before it is opened.\n *\n * `EdfFile.open` hands whatever it is given to `fs`, and the refusal comes back as an\n * `EdfError` coded `UNREADABLE`, hinted \"Check the path is spelled the way it is on disk and\n * that you have permission to read it\" — advice about a path, over a value that is not one,\n * filed as a problem with the recording rather than with the call. `convert({ input: 'a.edf' })`,\n * which is the option-bag shape the second parameter has, answered `Cannot read \"[object\n * Object]\"`; `convert(['a.edf', 'b.edf'])` answered `Cannot read \"a.edf,b.edf\"`, a path the\n * caller never wrote, because `String` of an array joins it with commas.\n *\n * The empty string is left to `fs`, which has no such file and says so truthfully — the same\n * reasoning `outputDir` states for not trimming: a path is not a keyword.\n */\nexport function assertInputPath(input: unknown): void {\n if (typeof input !== 'string') {\n throw new OptionError(`input must be a path to a recording, got ${describeValue(input)}.`);\n }\n}\n\n/**\n * How a rejected value reads in the refusal: numbers bare, everything else quoted so its\n * type is visible.\n *\n * `JSON.stringify` has no text for a function or a symbol — it returns `undefined`, not a\n * string — so `layout: () => 'long'` came back as `layout must be \"wide\" or \"long\", got\n * undefined.`, which names the one value that does not raise this: every option here is\n * optional, and `undefined` is how a caller says they are not passing it. `input` was worse,\n * since `convert(undefined)` and `convert(someFunction)` then produced the same sentence, and\n * the first is a forgotten argument while the second is a wrong one.\n *\n * Exported because time-range.ts had the identical function, fixed there and not here — the\n * same two-copies-of-one-helper the derived precision and the pluraliser were each pulled\n * together for.\n */\nexport function describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.8.87",
3
+ "version": "0.8.88",
4
4
  "description": "Convert EDF, EDF+ and BDF biosignal recordings (European Data Format) to CSV from the command line. Local, streaming, and never resamples or alters units.",
5
5
  "keywords": [
6
6
  "edf",