edf2csv 0.8.40 → 0.8.42

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.
@@ -282,8 +282,23 @@ export function selectChannels(signals, terms) {
282
282
  `Its events are already written to annotations.csv by any conversion of this ` +
283
283
  `file — pass --annotations-only for those and no signal data.`);
284
284
  }
285
+ /*
286
+ Advice that works in the mode it was given in.
287
+
288
+ `--info` takes `--channels` too — it is how you check a selection before converting
289
+ with it — and a term that matches nothing is refused there exactly as it is here. So
290
+ `edf2csv rec.edf --info --channels nope` answered:
291
+
292
+ error: No channel named "nope".
293
+ Run with --info to list the channels in this file.
294
+
295
+ which is the command that just printed it. The listing is what `--info` does; what
296
+ stopped it was the `--channels` beside it. Naming both makes the sentence true of a
297
+ conversion as well, where dropping `--channels` alone would convert the whole file
298
+ rather than list anything.
299
+ */
285
300
  throw new ChannelSelectionError(`No channel named "${term}".${hint}\n` +
286
- `Run with --info to list the channels in this file.`);
301
+ `Run with --info and no --channels to list the channels in this file.`);
287
302
  }
288
303
  if (matched.length > 1)
289
304
  ambiguous.push({ term, matched: [...matched] });
@@ -1 +1 @@
1
- {"version":3,"file":"channels.js","sourceRoot":"","sources":["../../src/convert/channels.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Channel naming and selection.\n *\n * Two real-world facts drive this module. EDF labels are free text that routinely\n * contain spaces and punctuation (\"EEG Fpz-Cz\"), and they are not guaranteed to be\n * unique — CHB-MIT recordings ship two channels both labelled \"T8-P8\", and some\n * carry a channel labelled \"-\". Labels are therefore preserved verbatim in output,\n * and only disambiguated when the file itself is ambiguous.\n */\n\nimport type { EdfSignal } from '../edf/header.js';\nimport { editDistance } from '../format/distance.js';\nimport { listed } from '../format/list.js';\nimport { assertOptions } from './options.js';\n\n/**\n * The name of the column the writer puts in front of the channels, which no channel may take.\n *\n * Exported and used by the writer rather than repeated there, because the whole point of\n * reserving it here is that the two cannot drift apart.\n */\nexport const TIME_COLUMN = 'time_s';\n\nexport class ChannelSelectionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ChannelSelectionError';\n }\n}\n\n/**\n * Column name for a signal.\n *\n * Names are derived from the whole file, not from the current selection, so a given\n * channel always produces the same column regardless of which channels were asked\n * for. Duplicated labels get a `_ch<index>` suffix pointing at their position in\n * the file, which is the only thing that reliably tells them apart.\n */\nexport function buildColumnNames(signals: readonly EdfSignal[]): Map<number, string> {\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n\n const names = new Map<number, string>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n const duplicated = (counts.get(base) ?? 0) > 1;\n names.set(signal.index, duplicated ? `${base}_ch${signal.index}` : base);\n }\n\n /*\n The suffix has to be checked against the file, not only against the label it disambiguates.\n\n Counting collisions on the raw label alone made the suffix a guess: `_ch<index>` is unique\n among the channels sharing that label, and nothing stopped it from landing on a label some\n other channel already had. A file labelled T8, T8, T8_ch0 — all three legal, since EDF\n labels are free text and nothing enforces uniqueness — produced\n\n time_s,T8_ch0,T8_ch1,T8_ch0\n\n two columns with one name, while the warning beside it said the suffix kept them\n \"distinguishable\". channels.csv listed the same name against two signal indices, so the\n join it exists for could not resolve it either, and `df[\"T8_ch0\"]` in pandas or R returns\n one of the two with nothing to say which.\n\n Anything still shared after the first pass takes its own position as well. That is unique\n by construction, so the loop settles immediately in practice; the bound is there because\n a second round could in principle land on yet another literal label.\n\n The time column counts as taken, for the same reason. It is a name this file does not\n supply and the writer does, and a channel labelled `time_s` collided with it in silence:\n the header came out `time_s,time_s,ECG`, channels.csv named the channel's column `time_s`,\n and every read-back the documentation gives — `index_col=\"time_s\"`, `pop(\"time_s\")`,\n `pivot(index=\"time_s\")` — resolves that to one of the two columns without saying which.\n pandas and Python's own `csv.DictReader` resolve it opposite ways round.\n */\n for (let round = 0; round < 8; round++) {\n const taken = new Map<string, number>([[TIME_COLUMN, 1]]);\n for (const name of names.values()) taken.set(name, (taken.get(name) ?? 0) + 1);\n\n const clashing = [...names].filter(([, name]) => (taken.get(name) ?? 0) > 1);\n if (clashing.length === 0) break;\n for (const [index, name] of clashing) names.set(index, `${name}_ch${index}`);\n }\n return names;\n}\n\nfunction baseName(signal: EdfSignal): string {\n return signal.label === '' ? `signal_${signal.index}` : signal.label;\n}\n\n/**\n * Channels whose column name was pushed off their own label to keep the header unique.\n *\n * A channel genuinely labelled `T8_ch0` loses that name when another label's disambiguating\n * suffix wants it, and the resulting column is the one thing in the output that no longer\n * matches the file. Silence there is what made the collision hard to see in the first place:\n * the only warning raised was about the *other* label.\n */\nexport function renamedByCollision(\n signals: readonly EdfSignal[],\n columnNames: ReadonlyMap<number, string>,\n): EdfSignal[] {\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n return signals.filter(\n (signal) =>\n !signal.isAnnotations &&\n (counts.get(baseName(signal)) ?? 0) === 1 &&\n columnNames.get(signal.index) !== baseName(signal),\n );\n}\n\nexport interface ChannelSelection {\n signals: EdfSignal[];\n /** Labels that matched more than one channel, so the user knows why they got extras. */\n ambiguous: { term: string; matched: EdfSignal[] }[];\n}\n\n/**\n * Resolve a `--channels` specification against the file's signals.\n *\n * Matching is case-insensitive on the exact label, with `#<index>` available to\n * address a specific channel when labels collide. A term that matches nothing is an\n * error rather than a silent omission: quietly dropping a requested channel would\n * hand the user a CSV that is missing data they explicitly asked for.\n */\nexport function selectChannels(signals: readonly EdfSignal[], terms: readonly string[]): ChannelSelection {\n /*\n The same check `buildPlan` makes, made here too, because this is exported on its own.\n\n `assertOptions` covers `convert`; a caller holding a header and calling this directly got\n the shapes it names in full. `'ECG'` was iterated character by character and answered `No\n channel named \"E\". Did you mean \"ECG\"?` — which reads like a real answer about the file —\n and `[1]`, a position written the way a position is written, came back as `TypeError:\n rawTerm.trim is not a function` from the loop below. Both are the argument being the wrong\n shape, which is the case that checker exists for, and both now say so.\n */\n assertOptions({ channels: terms });\n\n const candidates = signals.filter((s) => !s.isAnnotations);\n const byLabel = new Map<string, EdfSignal[]>();\n for (const signal of candidates) {\n const key = signal.label.toLowerCase();\n const bucket = byLabel.get(key);\n if (bucket) bucket.push(signal);\n else byLabel.set(key, [signal]);\n }\n\n const chosen = new Map<number, EdfSignal>();\n const ambiguous: { term: string; matched: EdfSignal[] }[] = [];\n /*\n A file may have no signal channels at all, and both position errors below assumed it had\n some. `listed([])` is the empty string, so `--channels \"#0\"` on an annotations-only\n recording — one of this tool's own fixtures — was refused with\n\n No channel at position #0. This file has signal channels at .\n\n a sentence that states there are channels and then names none, ending mid-clause. The\n conversion already raises NO_SIGNAL_CHANNELS for this file and says what it is, so the\n fact was known one layer up; the message that a `--channels` user actually reaches was\n the one that did not have it.\n */\n /*\n And one channel is not \"channels\", which is the same sentence one item further on.\n\n The zero case above was given its own wording because \"signal channels at .\" states there\n are some and then names none. A file with exactly one got \"This file has signal channels\n at #0.\" — a plural over a list of one, on the line whose job is to say what may be typed\n instead. Single-channel recordings are ordinary; two of this repository's own fixtures are\n one.\n */\n const positions =\n candidates.length === 0\n ? 'This file has no signal channels; it contains only annotations.'\n : candidates.length === 1\n ? `This file has one signal channel, at #${candidates[0]?.index ?? 0}.`\n : `This file has signal channels at ${listed(candidates.map((s) => `#${s.index}`))}.`;\n\n for (const rawTerm of terms) {\n const term = rawTerm.trim();\n if (term === '') continue;\n\n // '#N' addresses a channel by position, but a label may literally be \"#5\".\n // A real label always wins, so no channel becomes unreachable.\n const literal = byLabel.get(term.toLowerCase());\n if (term.startsWith('#') && (!literal || literal.length === 0)) {\n /*\n A position must be written in plain digits.\n\n `Number()` was doing the parsing, and it accepts a great deal more than a position:\n `#0x2` reached channel 2 through hex, `#0b1` channel 1 through binary, `#1e0` and\n `#2.0` and `# 2` all landed somewhere, and `#` on its own became `Number('')`, which\n is 0. Every one of them selected a channel and exited 0, so a slip did not fail —\n it quietly converted a different channel than the one asked for, which for this tool\n is the worst way to be wrong.\n */\n const position = term.slice(1);\n if (!/^\\d+$/u.test(position)) {\n throw new ChannelSelectionError(\n `\"${term}\" is not a channel position: a position is #0, #1, #2 and so on.\\n` +\n positions,\n );\n }\n const index = Number(position);\n const signal = candidates.find((s) => s.index === index);\n if (!signal) {\n /*\n The list of positions is advice, and advice goes on the continuation line.\n\n It sat on the first line here and on the second in the refusal six lines up, which\n is the same sentence about the same thing. The first line is the one a log gets\n grepped for and the one `printableLines` leaves whole at any width, so on a\n 40-channel recording this refusal ran to 111 columns where its neighbour ran to 74.\n */\n throw new ChannelSelectionError(\n `No channel at position ${term}.\\n${positions}`,\n );\n }\n chosen.set(signal.index, signal);\n continue;\n }\n\n const matched = byLabel.get(term.toLowerCase());\n if (!matched || matched.length === 0) {\n /*\n The one term that is certainly not a typo: a column name.\n\n Matching is on the label, and where a label collides the column gains a `_ch<index>`\n suffix — so `T8-P8_ch1` is a name this tool invented, prints in the COLUMN column of\n --info, writes into channels.csv and puts at the head of signals.csv, and then\n rejects with \"No channel named \"T8-P8_ch1\". Run with --info to list the channels in\n this file\", which is where the user copied it from. The reference documents the trap;\n the message a user actually hits did not.\n\n Answered where it is asked instead, and with the thing that works: `#<index>` selects\n one channel of a colliding pair, which is exactly what someone reaching for the\n suffixed column name wants and the only way to get it.\n */\n const columns = buildColumnNames(candidates);\n const owner = candidates.find(\n (signal) => (columns.get(signal.index) ?? '').toLowerCase() === term.toLowerCase(),\n );\n if (owner) {\n // A channel with no label at all gets the column `signal_<index>`, and offering its\n // label back would be offering `\"\"` — there is nothing to type. Position is the only\n // way to reach it, and saying so is more use than quoting an empty string twice.\n throw new ChannelSelectionError(\n owner.label === ''\n ? `\"${term}\" is a column name, not a channel name: --channels matches the label, ` +\n `and this channel has none.\\n` +\n `Use \"#${owner.index}\" — a channel with no label can only be addressed by position.`\n : `\"${term}\" is a column name, not a channel name: --channels matches the label, ` +\n `which for this channel is \"${owner.label}\".\\n` +\n (typeable(owner.label) === null\n ? `Use \"#${owner.index}\" — ${untypeableBecause(owner.label)}, so position is ` +\n `the only way to reach it.`\n : `Use \"#${owner.index}\" to select just this one, or ${typeable(owner.label)} ` +\n `for every channel sharing that label.`),\n );\n }\n /*\n The annotation channel is a channel, and this said the file had none by that name.\n\n `EDF Annotations` is a label the file really carries — the spec reserves it, --info\n counts it on the \"Channels\" line, and it is the name anyone reading about EDF+ meets\n first. Asking for it got \"No channel named \"EDF Annotations\". Run with --info to list\n the channels in this file\", which is false about the file and points at a table that\n does not list it either, so following the advice returns the reader to the same\n message. What they were after is already being written: every conversion of a file\n with this channel writes annotations.csv from it.\n */\n const asAnnotations = signals.find(\n (signal) => signal.isAnnotations && signal.label.toLowerCase() === term.toLowerCase(),\n );\n /*\n And a term that is merely near it, which the paragraph above describes and did not\n cover.\n\n The exact spelling got the whole explanation; one character off got \"No channel named\n \"EDF Annotation\". Run with --info to list the channels in this file\" — which is the\n sentence that comment calls false about the file and a pointer at a table that does\n not list the channel either. `EDF Annotations` on a BDF+ recording is the same miss by\n the prefix, and `--channels annotations` by everything but the noun; all three are how\n somebody who has read about EDF+ actually types it.\n\n Only when no signal label is close, so a suggestion about a real column always wins,\n and by the same distance rule that suggestion uses rather than a second one.\n */\n const hint = suggest(term, candidates);\n const nearAnnotations =\n hint === ''\n ? signals.find(\n (signal) =>\n signal.isAnnotations &&\n signal.label !== '' &&\n nearness(term, signal.label) <= reach(term),\n )\n : undefined;\n const annotationChannel = asAnnotations ?? nearAnnotations;\n if (annotationChannel) {\n const exact = annotationChannel.label.toLowerCase() === term.toLowerCase();\n throw new ChannelSelectionError(\n (exact\n ? `\"${term}\" is this recording's annotation channel, not a signal: it holds event `\n : `There is no channel named \"${term}\"; the nearest thing to it is this ` +\n `recording's annotation channel \"${annotationChannel.label}\", which holds event `) +\n `text rather than samples, so it has no column to select.\\n` +\n `Its events are already written to annotations.csv by any conversion of this ` +\n `file — pass --annotations-only for those and no signal data.`,\n );\n }\n throw new ChannelSelectionError(\n `No channel named \"${term}\".${hint}\\n` +\n `Run with --info to list the channels in this file.`,\n );\n }\n if (matched.length > 1) ambiguous.push({ term, matched: [...matched] });\n for (const signal of matched) chosen.set(signal.index, signal);\n }\n\n const selected = [...chosen.values()].sort((a, b) => a.index - b.index);\n if (selected.length === 0) {\n throw new ChannelSelectionError('No channels were selected.');\n }\n return { signals: selected, ambiguous };\n}\n\n/**\n * How far a label is from what was typed, and how far the suggestion reaches.\n *\n * Two functions ask the same question — the \"did you mean\" list, and the annotation channel\n * one branch above it — and a rule with two copies is a rule that will get two answers.\n */\nfunction nearness(term: string, label: string): number {\n const needle = term.toLowerCase();\n const lower = label.toLowerCase();\n return needle !== '' && lower.includes(needle) ? 0 : editDistance(needle, lower);\n}\n\nfunction reach(term: string): number {\n return Math.max(2, Math.floor(term.length / 3));\n}\n\nfunction suggest(term: string, candidates: readonly EdfSignal[]): string {\n // Duplicated labels would otherwise be suggested twice, which reads like two\n // different options while naming the same thing.\n const unique = [...new Set(candidates.map((s) => s.label))];\n /*\n A label that contains the term is the answer, however many edits away it is.\n\n Edit distance charges one edit per character the label has and the term does not, so an\n abbreviation — the commonest way to get this wrong — scores worse the more of the label it\n leaves out. On a recording whose channels are `EEG Fpz-Cz` and `ECG`, `--channels EEG` is\n seven edits from the channel it names and one from the other, and the answer was `Did you\n mean \"ECG\"?`: a suggestion that is retypeable, close, and about the wrong signal. Taking it\n converts a heart trace under the belief it is an EEG, and the run succeeds.\n */\n const scored = unique\n .map((label) => ({ label, distance: nearness(term, label) }))\n .filter((c) => c.label !== '' && c.distance <= reach(term))\n .sort((a, b) => a.distance - b.distance);\n if (scored.length === 0) return '';\n /*\n A suggestion is something to retype, so it has to be retypeable.\n\n `Did you mean \"EEG \"A1\"\"?` collapses in a shell to `EEG A1`, which this then rejects with\n the same sentence and the same suggestion — a loop the reader cannot get out of by doing\n what it says. Same failure the header parser's `--channels` advice was fixed for in\n 0.7.18, and the branch above it here; a label carrying `$` or a backtick is the same thing\n again, since a shell expands both inside double quotes.\n\n Where nothing can be typed — a label with a comma, which --channels splits after the shell\n has finished with it, or one with a control byte in it — the position is offered instead.\n It is not the name they asked about, but it is the answer to what they wanted.\n */\n /*\n Cut to three, and the rest counted rather than dropped.\n\n `.slice(0, 3)` said nothing about what it left. On a recording with channels ECG1 to ECG5,\n `--channels ECG` is one edit from all five and the answer was `Did you mean \"ECG1\", \"ECG2\",\n \"ECG3\"?` — three of five equally good answers, with nothing to say the list was cut. A\n reader has no way to tell ECG4 from a channel that does not exist, and this sentence is the\n only place the tool offers to tell them what does.\n\n Through `listed`, which every other list in a sentence goes through: it counts what it\n leaves, and shows a fourth item rather than hiding it behind a phrase longer than the item.\n */\n const offered = scored.map((c) => typeable(c.label) ?? `\"#${positionOf(c.label, candidates)}\"`);\n return ` Did you mean ${listed(offered, 3)}?`;\n}\n\n/** The first channel carrying this label, for a suggestion that cannot be made by name. */\nfunction positionOf(label: string, candidates: readonly EdfSignal[]): number {\n return candidates.find((signal) => signal.label === label)?.index ?? 0;\n}\n\n/**\n * The label written so that typing it back selects this channel, or null when nothing does.\n *\n * Double quotes wherever they survive, since they also show where the label begins and ends\n * and every documented example is written that way. They do not survive a label containing a\n * quote of their own, and — less obviously — a shell still expands `$`, a backtick and a\n * backslash inside them, so `EEG $ref` would arrive as `EEG ` with nothing said. Those go in\n * single quotes, the one POSIX form with no escapes inside it, where a single quote in the\n * label closes, escapes and reopens.\n *\n * Two labels have no form at all. `--channels` splits its list on commas after the shell has\n * finished quoting, so no quoting reaches a label with one in it; and a control character\n * cannot be typed. Both take a position instead, which is what NONPRINTABLE_LABEL already\n * says for the same two reasons.\n */\nexport function typeable(label: string): string | null {\n if (label === '' || label.includes(',')) return null;\n if (/[\\u0000-\\u001f\\u007f-\\u009f]/u.test(label)) return null;\n if (!/[\"$`\\\\]/u.test(label)) return `\"${label}\"`;\n return `'${label.replaceAll(\"'\", \"'\\\\''\")}'`;\n}\n\n/** Why a label has no typeable form, for the sentence that offers a position instead. */\nfunction untypeableBecause(label: string): string {\n if (label.includes(',')) return 'a comma in the label would read as two names';\n return 'the label cannot be typed';\n}\n"]}
1
+ {"version":3,"file":"channels.js","sourceRoot":"","sources":["../../src/convert/channels.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Channel naming and selection.\n *\n * Two real-world facts drive this module. EDF labels are free text that routinely\n * contain spaces and punctuation (\"EEG Fpz-Cz\"), and they are not guaranteed to be\n * unique — CHB-MIT recordings ship two channels both labelled \"T8-P8\", and some\n * carry a channel labelled \"-\". Labels are therefore preserved verbatim in output,\n * and only disambiguated when the file itself is ambiguous.\n */\n\nimport type { EdfSignal } from '../edf/header.js';\nimport { editDistance } from '../format/distance.js';\nimport { listed } from '../format/list.js';\nimport { assertOptions } from './options.js';\n\n/**\n * The name of the column the writer puts in front of the channels, which no channel may take.\n *\n * Exported and used by the writer rather than repeated there, because the whole point of\n * reserving it here is that the two cannot drift apart.\n */\nexport const TIME_COLUMN = 'time_s';\n\nexport class ChannelSelectionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ChannelSelectionError';\n }\n}\n\n/**\n * Column name for a signal.\n *\n * Names are derived from the whole file, not from the current selection, so a given\n * channel always produces the same column regardless of which channels were asked\n * for. Duplicated labels get a `_ch<index>` suffix pointing at their position in\n * the file, which is the only thing that reliably tells them apart.\n */\nexport function buildColumnNames(signals: readonly EdfSignal[]): Map<number, string> {\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n\n const names = new Map<number, string>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n const duplicated = (counts.get(base) ?? 0) > 1;\n names.set(signal.index, duplicated ? `${base}_ch${signal.index}` : base);\n }\n\n /*\n The suffix has to be checked against the file, not only against the label it disambiguates.\n\n Counting collisions on the raw label alone made the suffix a guess: `_ch<index>` is unique\n among the channels sharing that label, and nothing stopped it from landing on a label some\n other channel already had. A file labelled T8, T8, T8_ch0 — all three legal, since EDF\n labels are free text and nothing enforces uniqueness — produced\n\n time_s,T8_ch0,T8_ch1,T8_ch0\n\n two columns with one name, while the warning beside it said the suffix kept them\n \"distinguishable\". channels.csv listed the same name against two signal indices, so the\n join it exists for could not resolve it either, and `df[\"T8_ch0\"]` in pandas or R returns\n one of the two with nothing to say which.\n\n Anything still shared after the first pass takes its own position as well. That is unique\n by construction, so the loop settles immediately in practice; the bound is there because\n a second round could in principle land on yet another literal label.\n\n The time column counts as taken, for the same reason. It is a name this file does not\n supply and the writer does, and a channel labelled `time_s` collided with it in silence:\n the header came out `time_s,time_s,ECG`, channels.csv named the channel's column `time_s`,\n and every read-back the documentation gives — `index_col=\"time_s\"`, `pop(\"time_s\")`,\n `pivot(index=\"time_s\")` — resolves that to one of the two columns without saying which.\n pandas and Python's own `csv.DictReader` resolve it opposite ways round.\n */\n for (let round = 0; round < 8; round++) {\n const taken = new Map<string, number>([[TIME_COLUMN, 1]]);\n for (const name of names.values()) taken.set(name, (taken.get(name) ?? 0) + 1);\n\n const clashing = [...names].filter(([, name]) => (taken.get(name) ?? 0) > 1);\n if (clashing.length === 0) break;\n for (const [index, name] of clashing) names.set(index, `${name}_ch${index}`);\n }\n return names;\n}\n\nfunction baseName(signal: EdfSignal): string {\n return signal.label === '' ? `signal_${signal.index}` : signal.label;\n}\n\n/**\n * Channels whose column name was pushed off their own label to keep the header unique.\n *\n * A channel genuinely labelled `T8_ch0` loses that name when another label's disambiguating\n * suffix wants it, and the resulting column is the one thing in the output that no longer\n * matches the file. Silence there is what made the collision hard to see in the first place:\n * the only warning raised was about the *other* label.\n */\nexport function renamedByCollision(\n signals: readonly EdfSignal[],\n columnNames: ReadonlyMap<number, string>,\n): EdfSignal[] {\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n return signals.filter(\n (signal) =>\n !signal.isAnnotations &&\n (counts.get(baseName(signal)) ?? 0) === 1 &&\n columnNames.get(signal.index) !== baseName(signal),\n );\n}\n\nexport interface ChannelSelection {\n signals: EdfSignal[];\n /** Labels that matched more than one channel, so the user knows why they got extras. */\n ambiguous: { term: string; matched: EdfSignal[] }[];\n}\n\n/**\n * Resolve a `--channels` specification against the file's signals.\n *\n * Matching is case-insensitive on the exact label, with `#<index>` available to\n * address a specific channel when labels collide. A term that matches nothing is an\n * error rather than a silent omission: quietly dropping a requested channel would\n * hand the user a CSV that is missing data they explicitly asked for.\n */\nexport function selectChannels(signals: readonly EdfSignal[], terms: readonly string[]): ChannelSelection {\n /*\n The same check `buildPlan` makes, made here too, because this is exported on its own.\n\n `assertOptions` covers `convert`; a caller holding a header and calling this directly got\n the shapes it names in full. `'ECG'` was iterated character by character and answered `No\n channel named \"E\". Did you mean \"ECG\"?` — which reads like a real answer about the file —\n and `[1]`, a position written the way a position is written, came back as `TypeError:\n rawTerm.trim is not a function` from the loop below. Both are the argument being the wrong\n shape, which is the case that checker exists for, and both now say so.\n */\n assertOptions({ channels: terms });\n\n const candidates = signals.filter((s) => !s.isAnnotations);\n const byLabel = new Map<string, EdfSignal[]>();\n for (const signal of candidates) {\n const key = signal.label.toLowerCase();\n const bucket = byLabel.get(key);\n if (bucket) bucket.push(signal);\n else byLabel.set(key, [signal]);\n }\n\n const chosen = new Map<number, EdfSignal>();\n const ambiguous: { term: string; matched: EdfSignal[] }[] = [];\n /*\n A file may have no signal channels at all, and both position errors below assumed it had\n some. `listed([])` is the empty string, so `--channels \"#0\"` on an annotations-only\n recording — one of this tool's own fixtures — was refused with\n\n No channel at position #0. This file has signal channels at .\n\n a sentence that states there are channels and then names none, ending mid-clause. The\n conversion already raises NO_SIGNAL_CHANNELS for this file and says what it is, so the\n fact was known one layer up; the message that a `--channels` user actually reaches was\n the one that did not have it.\n */\n /*\n And one channel is not \"channels\", which is the same sentence one item further on.\n\n The zero case above was given its own wording because \"signal channels at .\" states there\n are some and then names none. A file with exactly one got \"This file has signal channels\n at #0.\" — a plural over a list of one, on the line whose job is to say what may be typed\n instead. Single-channel recordings are ordinary; two of this repository's own fixtures are\n one.\n */\n const positions =\n candidates.length === 0\n ? 'This file has no signal channels; it contains only annotations.'\n : candidates.length === 1\n ? `This file has one signal channel, at #${candidates[0]?.index ?? 0}.`\n : `This file has signal channels at ${listed(candidates.map((s) => `#${s.index}`))}.`;\n\n for (const rawTerm of terms) {\n const term = rawTerm.trim();\n if (term === '') continue;\n\n // '#N' addresses a channel by position, but a label may literally be \"#5\".\n // A real label always wins, so no channel becomes unreachable.\n const literal = byLabel.get(term.toLowerCase());\n if (term.startsWith('#') && (!literal || literal.length === 0)) {\n /*\n A position must be written in plain digits.\n\n `Number()` was doing the parsing, and it accepts a great deal more than a position:\n `#0x2` reached channel 2 through hex, `#0b1` channel 1 through binary, `#1e0` and\n `#2.0` and `# 2` all landed somewhere, and `#` on its own became `Number('')`, which\n is 0. Every one of them selected a channel and exited 0, so a slip did not fail —\n it quietly converted a different channel than the one asked for, which for this tool\n is the worst way to be wrong.\n */\n const position = term.slice(1);\n if (!/^\\d+$/u.test(position)) {\n throw new ChannelSelectionError(\n `\"${term}\" is not a channel position: a position is #0, #1, #2 and so on.\\n` +\n positions,\n );\n }\n const index = Number(position);\n const signal = candidates.find((s) => s.index === index);\n if (!signal) {\n /*\n The list of positions is advice, and advice goes on the continuation line.\n\n It sat on the first line here and on the second in the refusal six lines up, which\n is the same sentence about the same thing. The first line is the one a log gets\n grepped for and the one `printableLines` leaves whole at any width, so on a\n 40-channel recording this refusal ran to 111 columns where its neighbour ran to 74.\n */\n throw new ChannelSelectionError(\n `No channel at position ${term}.\\n${positions}`,\n );\n }\n chosen.set(signal.index, signal);\n continue;\n }\n\n const matched = byLabel.get(term.toLowerCase());\n if (!matched || matched.length === 0) {\n /*\n The one term that is certainly not a typo: a column name.\n\n Matching is on the label, and where a label collides the column gains a `_ch<index>`\n suffix — so `T8-P8_ch1` is a name this tool invented, prints in the COLUMN column of\n --info, writes into channels.csv and puts at the head of signals.csv, and then\n rejects with \"No channel named \"T8-P8_ch1\". Run with --info to list the channels in\n this file\", which is where the user copied it from. The reference documents the trap;\n the message a user actually hits did not.\n\n Answered where it is asked instead, and with the thing that works: `#<index>` selects\n one channel of a colliding pair, which is exactly what someone reaching for the\n suffixed column name wants and the only way to get it.\n */\n const columns = buildColumnNames(candidates);\n const owner = candidates.find(\n (signal) => (columns.get(signal.index) ?? '').toLowerCase() === term.toLowerCase(),\n );\n if (owner) {\n // A channel with no label at all gets the column `signal_<index>`, and offering its\n // label back would be offering `\"\"` — there is nothing to type. Position is the only\n // way to reach it, and saying so is more use than quoting an empty string twice.\n throw new ChannelSelectionError(\n owner.label === ''\n ? `\"${term}\" is a column name, not a channel name: --channels matches the label, ` +\n `and this channel has none.\\n` +\n `Use \"#${owner.index}\" — a channel with no label can only be addressed by position.`\n : `\"${term}\" is a column name, not a channel name: --channels matches the label, ` +\n `which for this channel is \"${owner.label}\".\\n` +\n (typeable(owner.label) === null\n ? `Use \"#${owner.index}\" — ${untypeableBecause(owner.label)}, so position is ` +\n `the only way to reach it.`\n : `Use \"#${owner.index}\" to select just this one, or ${typeable(owner.label)} ` +\n `for every channel sharing that label.`),\n );\n }\n /*\n The annotation channel is a channel, and this said the file had none by that name.\n\n `EDF Annotations` is a label the file really carries — the spec reserves it, --info\n counts it on the \"Channels\" line, and it is the name anyone reading about EDF+ meets\n first. Asking for it got \"No channel named \"EDF Annotations\". Run with --info to list\n the channels in this file\", which is false about the file and points at a table that\n does not list it either, so following the advice returns the reader to the same\n message. What they were after is already being written: every conversion of a file\n with this channel writes annotations.csv from it.\n */\n const asAnnotations = signals.find(\n (signal) => signal.isAnnotations && signal.label.toLowerCase() === term.toLowerCase(),\n );\n /*\n And a term that is merely near it, which the paragraph above describes and did not\n cover.\n\n The exact spelling got the whole explanation; one character off got \"No channel named\n \"EDF Annotation\". Run with --info to list the channels in this file\" — which is the\n sentence that comment calls false about the file and a pointer at a table that does\n not list the channel either. `EDF Annotations` on a BDF+ recording is the same miss by\n the prefix, and `--channels annotations` by everything but the noun; all three are how\n somebody who has read about EDF+ actually types it.\n\n Only when no signal label is close, so a suggestion about a real column always wins,\n and by the same distance rule that suggestion uses rather than a second one.\n */\n const hint = suggest(term, candidates);\n const nearAnnotations =\n hint === ''\n ? signals.find(\n (signal) =>\n signal.isAnnotations &&\n signal.label !== '' &&\n nearness(term, signal.label) <= reach(term),\n )\n : undefined;\n const annotationChannel = asAnnotations ?? nearAnnotations;\n if (annotationChannel) {\n const exact = annotationChannel.label.toLowerCase() === term.toLowerCase();\n throw new ChannelSelectionError(\n (exact\n ? `\"${term}\" is this recording's annotation channel, not a signal: it holds event `\n : `There is no channel named \"${term}\"; the nearest thing to it is this ` +\n `recording's annotation channel \"${annotationChannel.label}\", which holds event `) +\n `text rather than samples, so it has no column to select.\\n` +\n `Its events are already written to annotations.csv by any conversion of this ` +\n `file — pass --annotations-only for those and no signal data.`,\n );\n }\n /*\n Advice that works in the mode it was given in.\n\n `--info` takes `--channels` too — it is how you check a selection before converting\n with it — and a term that matches nothing is refused there exactly as it is here. So\n `edf2csv rec.edf --info --channels nope` answered:\n\n error: No channel named \"nope\".\n Run with --info to list the channels in this file.\n\n which is the command that just printed it. The listing is what `--info` does; what\n stopped it was the `--channels` beside it. Naming both makes the sentence true of a\n conversion as well, where dropping `--channels` alone would convert the whole file\n rather than list anything.\n */\n throw new ChannelSelectionError(\n `No channel named \"${term}\".${hint}\\n` +\n `Run with --info and no --channels to list the channels in this file.`,\n );\n }\n if (matched.length > 1) ambiguous.push({ term, matched: [...matched] });\n for (const signal of matched) chosen.set(signal.index, signal);\n }\n\n const selected = [...chosen.values()].sort((a, b) => a.index - b.index);\n if (selected.length === 0) {\n throw new ChannelSelectionError('No channels were selected.');\n }\n return { signals: selected, ambiguous };\n}\n\n/**\n * How far a label is from what was typed, and how far the suggestion reaches.\n *\n * Two functions ask the same question — the \"did you mean\" list, and the annotation channel\n * one branch above it — and a rule with two copies is a rule that will get two answers.\n */\nfunction nearness(term: string, label: string): number {\n const needle = term.toLowerCase();\n const lower = label.toLowerCase();\n return needle !== '' && lower.includes(needle) ? 0 : editDistance(needle, lower);\n}\n\nfunction reach(term: string): number {\n return Math.max(2, Math.floor(term.length / 3));\n}\n\nfunction suggest(term: string, candidates: readonly EdfSignal[]): string {\n // Duplicated labels would otherwise be suggested twice, which reads like two\n // different options while naming the same thing.\n const unique = [...new Set(candidates.map((s) => s.label))];\n /*\n A label that contains the term is the answer, however many edits away it is.\n\n Edit distance charges one edit per character the label has and the term does not, so an\n abbreviation — the commonest way to get this wrong — scores worse the more of the label it\n leaves out. On a recording whose channels are `EEG Fpz-Cz` and `ECG`, `--channels EEG` is\n seven edits from the channel it names and one from the other, and the answer was `Did you\n mean \"ECG\"?`: a suggestion that is retypeable, close, and about the wrong signal. Taking it\n converts a heart trace under the belief it is an EEG, and the run succeeds.\n */\n const scored = unique\n .map((label) => ({ label, distance: nearness(term, label) }))\n .filter((c) => c.label !== '' && c.distance <= reach(term))\n .sort((a, b) => a.distance - b.distance);\n if (scored.length === 0) return '';\n /*\n A suggestion is something to retype, so it has to be retypeable.\n\n `Did you mean \"EEG \"A1\"\"?` collapses in a shell to `EEG A1`, which this then rejects with\n the same sentence and the same suggestion — a loop the reader cannot get out of by doing\n what it says. Same failure the header parser's `--channels` advice was fixed for in\n 0.7.18, and the branch above it here; a label carrying `$` or a backtick is the same thing\n again, since a shell expands both inside double quotes.\n\n Where nothing can be typed — a label with a comma, which --channels splits after the shell\n has finished with it, or one with a control byte in it — the position is offered instead.\n It is not the name they asked about, but it is the answer to what they wanted.\n */\n /*\n Cut to three, and the rest counted rather than dropped.\n\n `.slice(0, 3)` said nothing about what it left. On a recording with channels ECG1 to ECG5,\n `--channels ECG` is one edit from all five and the answer was `Did you mean \"ECG1\", \"ECG2\",\n \"ECG3\"?` — three of five equally good answers, with nothing to say the list was cut. A\n reader has no way to tell ECG4 from a channel that does not exist, and this sentence is the\n only place the tool offers to tell them what does.\n\n Through `listed`, which every other list in a sentence goes through: it counts what it\n leaves, and shows a fourth item rather than hiding it behind a phrase longer than the item.\n */\n const offered = scored.map((c) => typeable(c.label) ?? `\"#${positionOf(c.label, candidates)}\"`);\n return ` Did you mean ${listed(offered, 3)}?`;\n}\n\n/** The first channel carrying this label, for a suggestion that cannot be made by name. */\nfunction positionOf(label: string, candidates: readonly EdfSignal[]): number {\n return candidates.find((signal) => signal.label === label)?.index ?? 0;\n}\n\n/**\n * The label written so that typing it back selects this channel, or null when nothing does.\n *\n * Double quotes wherever they survive, since they also show where the label begins and ends\n * and every documented example is written that way. They do not survive a label containing a\n * quote of their own, and — less obviously — a shell still expands `$`, a backtick and a\n * backslash inside them, so `EEG $ref` would arrive as `EEG ` with nothing said. Those go in\n * single quotes, the one POSIX form with no escapes inside it, where a single quote in the\n * label closes, escapes and reopens.\n *\n * Two labels have no form at all. `--channels` splits its list on commas after the shell has\n * finished quoting, so no quoting reaches a label with one in it; and a control character\n * cannot be typed. Both take a position instead, which is what NONPRINTABLE_LABEL already\n * says for the same two reasons.\n */\nexport function typeable(label: string): string | null {\n if (label === '' || label.includes(',')) return null;\n if (/[\\u0000-\\u001f\\u007f-\\u009f]/u.test(label)) return null;\n if (!/[\"$`\\\\]/u.test(label)) return `\"${label}\"`;\n return `'${label.replaceAll(\"'\", \"'\\\\''\")}'`;\n}\n\n/** Why a label has no typeable form, for the sentence that offers a position instead. */\nfunction untypeableBecause(label: string): string {\n if (label.includes(',')) return 'a comma in the label would read as two names';\n return 'the label cannot be typed';\n}\n"]}
@@ -46,7 +46,24 @@ export function listed(items, limit = DEFAULT_LIMIT) {
46
46
  if (items.length <= limit + 1)
47
47
  return items.join(', ');
48
48
  const shown = items.slice(0, limit).join(', ');
49
- return `${shown} and ${items.length - limit} more`;
49
+ /*
50
+ Grouped, like every other count this tool prints.
51
+
52
+ This one is written in the same sentences as the grouped ones and was the only bare
53
+ number among them. A discontinuous recording whose timekeeping is unreadable throughout
54
+ put all three side by side:
55
+
56
+ 1,010 of 1,010 data records carry no readable timekeeping annotation
57
+ (records 0, 1, 2, 3, 4, 5, 6, 7 and 1002 more), so their true position in
58
+ time is unknown.
59
+
60
+ Two counts of the same set of records, a comma apart, spelled two ways — and the reader
61
+ is meant to subtract one from the other to see how many were named. `grouped`'s own
62
+ comment is about exactly that: "two figures sit in one sentence to be compared against
63
+ each other". The tail count escaped it because it is built here rather than at a call
64
+ site, and `counted` was the only door into `grouped`.
65
+ */
66
+ return `${shown} and ${grouped(items.length - limit)} more`;
50
67
  }
51
68
  /**
52
69
  * A count and its noun, agreeing.
@@ -1 +1 @@
1
- {"version":3,"file":"list.js","sourceRoot":"","sources":["../../src/format/list.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,kEAAkE;AAClE,MAAM,aAAa,GAAG,CAAC,CAAC;AAExB;;;;;;;;;GASG;AACH,MAAM,UAAU,MAAM,CAAC,KAAwB,EAAE,KAAK,GAAG,aAAa;IACpE;;;;;;;;;;;;;;;MAeE;IACF,IAAI,KAAK,CAAC,MAAM,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvD,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC/C,OAAO,GAAG,KAAK,QAAQ,KAAK,CAAC,MAAM,GAAG,KAAK,OAAO,CAAC;AACrD,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,OAAO,CAAC,CAAS,EAAE,QAAgB,EAAE,MAAM,GAAG,GAAG,QAAQ,GAAG;IAC1E,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;AACxD,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,OAAO,CAAC,CAAS;IAC/B,OAAO,CAAC,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;AACnC,CAAC","sourcesContent":["/**\n * Rendering a set of things inside a sentence, without letting the file decide how long\n * the sentence gets.\n *\n * Several messages enumerate something the recording controls: its sampling rates, its\n * channel positions. On an ordinary file that is a handful of items and listing them all is\n * exactly right — the rates are what `--channels` has to choose between, so naming them is\n * the whole use of the message. On a file with two hundred channels the same code produced a\n * single 1,600-character line:\n *\n * warning: Channels use 200 different sampling rates (200 Hz, 199 Hz, 198 Hz, ... 1 Hz).\n *\n * which wraps across a whole terminal and buries the sentence that mattered. Nothing was\n * wrong with the conversion; the message was simply unreadable at a size the header is free\n * to ask for.\n */\n\n/** How many items a message shows before summarising the rest. */\nconst DEFAULT_LIMIT = 8;\n\n/**\n * Join items for a sentence, keeping at most `limit` of them.\n *\n * Beyond the limit the remainder is counted rather than named. The count is the honest part:\n * it says the list was cut without pretending the tail does not exist.\n *\n * listed(['1 Hz', '2 Hz']) -> '1 Hz, 2 Hz'\n * listed(rates9) -> all nine; see below\n * listed(rates200) -> '200 Hz, 199 Hz, ... 193 Hz and 192 more'\n */\nexport function listed(items: readonly string[], limit = DEFAULT_LIMIT): string {\n /*\n One item over the limit is shown rather than counted.\n\n \"and 1 more\" is eleven characters standing in for a single item, and an item is rarely\n that long. Nine sampling rates came out as\n\n Channels use 9 different sampling rates (100 Hz, 99 Hz, 98 Hz, 97 Hz, 96 Hz,\n 95 Hz, 94 Hz, 93 Hz and 1 more).\n\n which is four characters longer than naming all nine and one rate shorter — while the\n sentence around it has already said there are nine, so the reader is told the count and\n then denied the item. A cut that costs more than it hides is not a cut.\n\n Two is where it starts paying: \", 92 Hz, 91 Hz\" against \" and 2 more\". Beyond that it is\n the whole point.\n */\n if (items.length <= limit + 1) return items.join(', ');\n const shown = items.slice(0, limit).join(', ');\n return `${shown} and ${items.length - limit} more`;\n}\n\n/**\n * A count and its noun, agreeing.\n *\n * Every one of these was written `${n} records`, which is right until the file has one of\n * them — and a one-record recording, a one-byte tail and a batch of one are all ordinary.\n * `--info` opened with \"Duration 1s (1 records of 1s)\" and a truncated file warned that\n * \"1 bytes after the last complete data record were ignored\". Small, and on the two lines a\n * reader looks at first.\n *\n * The plural is `<singular>s` unless given, since English mostly cooperates here and the\n * exceptions in this codebase — \"entries\" — are spelled out at the call site.\n *\n * counted(1, 'record') -> '1 record'\n * counted(4, 'record') -> '4 records'\n * counted(1, 'entry', 'entries') -> '1 entry'\n */\nexport function counted(n: number, singular: string, plural = `${singular}s`): string {\n return `${grouped(n)} ${n === 1 ? singular : plural}`;\n}\n\n/**\n * A count, in the digit grouping the rest of this tool's counts are printed in.\n *\n * `--info` printed two counts of the same recording ten lines apart:\n *\n * Duration 8h 00m 0s (28800 records of 1s)\n * Would write 3,196,800 rows, roughly 108 MB.\n *\n * and the warning under them, \"more than 1,048,576 rows, which is more than Excel or Numbers\n * can open\", made three. Row counts were grouped because they were written that way; every\n * count that went through `counted` was not, because it was written the other way. Nothing\n * decided that — the two were never compared.\n *\n * Which matters most where two figures sit in one sentence to be compared against each\n * other: \"The file contains 39321 bytes of data, which is less than the 65536 its header\n * says one data record takes\" is a subtraction the reader is being asked to do by eye.\n *\n * Named so the neighbours of a `counted` call can be printed the same way without a third\n * copy of the locale string, and so there is one place to change if that is ever wrong.\n */\nexport function grouped(n: number): string {\n return n.toLocaleString('en-US');\n}\n"]}
1
+ {"version":3,"file":"list.js","sourceRoot":"","sources":["../../src/format/list.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,kEAAkE;AAClE,MAAM,aAAa,GAAG,CAAC,CAAC;AAExB;;;;;;;;;GASG;AACH,MAAM,UAAU,MAAM,CAAC,KAAwB,EAAE,KAAK,GAAG,aAAa;IACpE;;;;;;;;;;;;;;;MAeE;IACF,IAAI,KAAK,CAAC,MAAM,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvD,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC/C;;;;;;;;;;;;;;;;MAgBE;IACF,OAAO,GAAG,KAAK,QAAQ,OAAO,CAAC,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC;AAC9D,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,OAAO,CAAC,CAAS,EAAE,QAAgB,EAAE,MAAM,GAAG,GAAG,QAAQ,GAAG;IAC1E,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;AACxD,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,OAAO,CAAC,CAAS;IAC/B,OAAO,CAAC,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;AACnC,CAAC","sourcesContent":["/**\n * Rendering a set of things inside a sentence, without letting the file decide how long\n * the sentence gets.\n *\n * Several messages enumerate something the recording controls: its sampling rates, its\n * channel positions. On an ordinary file that is a handful of items and listing them all is\n * exactly right — the rates are what `--channels` has to choose between, so naming them is\n * the whole use of the message. On a file with two hundred channels the same code produced a\n * single 1,600-character line:\n *\n * warning: Channels use 200 different sampling rates (200 Hz, 199 Hz, 198 Hz, ... 1 Hz).\n *\n * which wraps across a whole terminal and buries the sentence that mattered. Nothing was\n * wrong with the conversion; the message was simply unreadable at a size the header is free\n * to ask for.\n */\n\n/** How many items a message shows before summarising the rest. */\nconst DEFAULT_LIMIT = 8;\n\n/**\n * Join items for a sentence, keeping at most `limit` of them.\n *\n * Beyond the limit the remainder is counted rather than named. The count is the honest part:\n * it says the list was cut without pretending the tail does not exist.\n *\n * listed(['1 Hz', '2 Hz']) -> '1 Hz, 2 Hz'\n * listed(rates9) -> all nine; see below\n * listed(rates200) -> '200 Hz, 199 Hz, ... 193 Hz and 192 more'\n */\nexport function listed(items: readonly string[], limit = DEFAULT_LIMIT): string {\n /*\n One item over the limit is shown rather than counted.\n\n \"and 1 more\" is eleven characters standing in for a single item, and an item is rarely\n that long. Nine sampling rates came out as\n\n Channels use 9 different sampling rates (100 Hz, 99 Hz, 98 Hz, 97 Hz, 96 Hz,\n 95 Hz, 94 Hz, 93 Hz and 1 more).\n\n which is four characters longer than naming all nine and one rate shorter — while the\n sentence around it has already said there are nine, so the reader is told the count and\n then denied the item. A cut that costs more than it hides is not a cut.\n\n Two is where it starts paying: \", 92 Hz, 91 Hz\" against \" and 2 more\". Beyond that it is\n the whole point.\n */\n if (items.length <= limit + 1) return items.join(', ');\n const shown = items.slice(0, limit).join(', ');\n /*\n Grouped, like every other count this tool prints.\n\n This one is written in the same sentences as the grouped ones and was the only bare\n number among them. A discontinuous recording whose timekeeping is unreadable throughout\n put all three side by side:\n\n 1,010 of 1,010 data records carry no readable timekeeping annotation\n (records 0, 1, 2, 3, 4, 5, 6, 7 and 1002 more), so their true position in\n time is unknown.\n\n Two counts of the same set of records, a comma apart, spelled two ways — and the reader\n is meant to subtract one from the other to see how many were named. `grouped`'s own\n comment is about exactly that: \"two figures sit in one sentence to be compared against\n each other\". The tail count escaped it because it is built here rather than at a call\n site, and `counted` was the only door into `grouped`.\n */\n return `${shown} and ${grouped(items.length - limit)} more`;\n}\n\n/**\n * A count and its noun, agreeing.\n *\n * Every one of these was written `${n} records`, which is right until the file has one of\n * them — and a one-record recording, a one-byte tail and a batch of one are all ordinary.\n * `--info` opened with \"Duration 1s (1 records of 1s)\" and a truncated file warned that\n * \"1 bytes after the last complete data record were ignored\". Small, and on the two lines a\n * reader looks at first.\n *\n * The plural is `<singular>s` unless given, since English mostly cooperates here and the\n * exceptions in this codebase — \"entries\" — are spelled out at the call site.\n *\n * counted(1, 'record') -> '1 record'\n * counted(4, 'record') -> '4 records'\n * counted(1, 'entry', 'entries') -> '1 entry'\n */\nexport function counted(n: number, singular: string, plural = `${singular}s`): string {\n return `${grouped(n)} ${n === 1 ? singular : plural}`;\n}\n\n/**\n * A count, in the digit grouping the rest of this tool's counts are printed in.\n *\n * `--info` printed two counts of the same recording ten lines apart:\n *\n * Duration 8h 00m 0s (28800 records of 1s)\n * Would write 3,196,800 rows, roughly 108 MB.\n *\n * and the warning under them, \"more than 1,048,576 rows, which is more than Excel or Numbers\n * can open\", made three. Row counts were grouped because they were written that way; every\n * count that went through `counted` was not, because it was written the other way. Nothing\n * decided that — the two were never compared.\n *\n * Which matters most where two figures sit in one sentence to be compared against each\n * other: \"The file contains 39321 bytes of data, which is less than the 65536 its header\n * says one data record takes\" is a subtraction the reader is being asked to do by eye.\n *\n * Named so the neighbours of a `counted` call can be printed the same way without a third\n * copy of the locale string, and so there is one place to change if that is ever wrong.\n */\nexport function grouped(n: number): string {\n return n.toLocaleString('en-US');\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.8.40",
3
+ "version": "0.8.42",
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",