edf2csv 0.7.260 → 0.8.1

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.
@@ -106,6 +106,37 @@ export declare function durationDiagnostics(annotations: readonly Annotation[],
106
106
  from: number;
107
107
  to: number;
108
108
  }): Diagnostic[];
109
+ /**
110
+ * What the descriptions in the events that will actually be written look like.
111
+ *
112
+ * EDF's four free-text header fields have had two warnings about where they land since they
113
+ * were written: `FORMULA_LABEL` for text a spreadsheet runs instead of reading, and
114
+ * `NONPRINTABLE_LABEL` for bytes that drive a terminal. Both say the same thing about the
115
+ * remedy — the text is written exactly as the file has it, because rewriting it would mean
116
+ * the CSV no longer says what the recording says — and both exist so that the tool is not
117
+ * silent about where it goes.
118
+ *
119
+ * `annotations.csv`'s `description` column is the same kind of text, out of the same file,
120
+ * into the same spreadsheet, and nothing was said about it at all. An event described
121
+ * `=HYPERLINK("http://…","Sleep stage W")` was written verbatim, exit 0, no warning, and
122
+ * opens as a live link nobody in the reading chain wrote; one carrying `\x1b[31m` turns the
123
+ * terminal red on `cat annotations.csv`. It is the more likely of the two to happen by
124
+ * accident, since a description is typed by a person at a scoring station while a channel
125
+ * label is written once by the recorder.
126
+ *
127
+ * It is also the only free text in the output that can carry a character above U+00FF —
128
+ * header text is decoded latin1, so every byte of it becomes a code point below U+0100, and
129
+ * a bidirectional override cannot reach a label. It can reach a description, which is UTF-8.
130
+ *
131
+ * Counted rather than raised per event, unlike the header's four fields: a night's scoring is
132
+ * thousands of events, and a warning each is not a report. The count is of the rows that
133
+ * reach `annotations.csv`, after the same window filter the writer applies, for the reason
134
+ * `durationDiagnostics` beside it gives.
135
+ */
136
+ export declare function descriptionDiagnostics(annotations: readonly Annotation[], window: {
137
+ from: number;
138
+ to: number;
139
+ }): Diagnostic[];
109
140
  /**
110
141
  * The window annotations are filtered by — the bounds as asked for, not as snapped to records.
111
142
  *
@@ -11,10 +11,11 @@ import { finished } from 'node:stream/promises';
11
11
  import { createGzip, gzipSync } from 'node:zlib';
12
12
  import path from 'node:path';
13
13
  import { EdfFile } from '../edf/reader.js';
14
- import { describeFormat, formatRates, formatWallClock } from '../edf/header.js';
14
+ import { describeFormat, formatRates, formatWallClock, startsFormula } from '../edf/header.js';
15
15
  import { EdfError } from '../edf/errors.js';
16
16
  import { BufferedLineWriter, DEFAULT_FLUSH_THRESHOLD, UTF8_BOM, csvRow, escapeCsvField, } from '../format/csv.js';
17
17
  import { counted, listed } from '../format/list.js';
18
+ import { escapeCharacter, unprintableIn } from '../format/unprintable.js';
18
19
  import { makeSampleFormatter, makeTimeFormatter, newOffsetBudget, newSampleCacheBudget, plain, } from '../format/number.js';
19
20
  import { TIME_COLUMN } from './channels.js';
20
21
  import { assertInputPath } from './options.js';
@@ -147,6 +148,7 @@ export async function convert(inputPath, options = {}) {
147
148
  // Reported against the rows that will be written, not against the file; see
148
149
  // durationDiagnostics.
149
150
  plan.diagnostics.push(...durationDiagnostics(annotationData.annotations, window));
151
+ plan.diagnostics.push(...descriptionDiagnostics(annotationData.annotations, window));
150
152
  const result = await writeAnnotationsCsv(outputDir, annotationData.annotations, window, options.gzip === true, options.bom === true);
151
153
  written.push(result);
152
154
  annotationsWritten = result.rows;
@@ -1048,6 +1050,70 @@ export function durationDiagnostics(annotations, window) {
1048
1050
  }
1049
1051
  return diagnostics;
1050
1052
  }
1053
+ /**
1054
+ * What the descriptions in the events that will actually be written look like.
1055
+ *
1056
+ * EDF's four free-text header fields have had two warnings about where they land since they
1057
+ * were written: `FORMULA_LABEL` for text a spreadsheet runs instead of reading, and
1058
+ * `NONPRINTABLE_LABEL` for bytes that drive a terminal. Both say the same thing about the
1059
+ * remedy — the text is written exactly as the file has it, because rewriting it would mean
1060
+ * the CSV no longer says what the recording says — and both exist so that the tool is not
1061
+ * silent about where it goes.
1062
+ *
1063
+ * `annotations.csv`'s `description` column is the same kind of text, out of the same file,
1064
+ * into the same spreadsheet, and nothing was said about it at all. An event described
1065
+ * `=HYPERLINK("http://…","Sleep stage W")` was written verbatim, exit 0, no warning, and
1066
+ * opens as a live link nobody in the reading chain wrote; one carrying `\x1b[31m` turns the
1067
+ * terminal red on `cat annotations.csv`. It is the more likely of the two to happen by
1068
+ * accident, since a description is typed by a person at a scoring station while a channel
1069
+ * label is written once by the recorder.
1070
+ *
1071
+ * It is also the only free text in the output that can carry a character above U+00FF —
1072
+ * header text is decoded latin1, so every byte of it becomes a code point below U+0100, and
1073
+ * a bidirectional override cannot reach a label. It can reach a description, which is UTF-8.
1074
+ *
1075
+ * Counted rather than raised per event, unlike the header's four fields: a night's scoring is
1076
+ * thousands of events, and a warning each is not a report. The count is of the rows that
1077
+ * reach `annotations.csv`, after the same window filter the writer applies, for the reason
1078
+ * `durationDiagnostics` beside it gives.
1079
+ */
1080
+ export function descriptionDiagnostics(annotations, window) {
1081
+ const written = annotations.filter((a) => a.onset >= window.from && a.onset < window.to);
1082
+ const diagnostics = [];
1083
+ const formulaic = written.filter((a) => startsFormula(a.text));
1084
+ if (formulaic.length > 0) {
1085
+ const one = formulaic.length === 1;
1086
+ const shown = [...new Set(formulaic.map((a) => a.text[0]))].join(', ');
1087
+ diagnostics.push({
1088
+ code: 'FORMULA_LABEL',
1089
+ severity: 'warning',
1090
+ message: `${counted(formulaic.length, 'annotation')} ${one ? 'has a description' : 'have descriptions'} ` +
1091
+ `starting with ${shown}, which Excel, LibreOffice and Google Sheets read as the start ` +
1092
+ `of a formula rather than as text.`,
1093
+ hint: 'The text is written to annotations.csv exactly as the file has it, so the cell is ' +
1094
+ 'what the recording says. Open the CSV with pandas or R, or import it into the ' +
1095
+ 'spreadsheet as text, if you do not want it evaluated.',
1096
+ });
1097
+ }
1098
+ const marked = written.filter((a) => unprintableIn(a.text).length > 0);
1099
+ if (marked.length > 0) {
1100
+ const one = marked.length === 1;
1101
+ const shown = [...new Set(marked.flatMap((a) => unprintableIn(a.text)))]
1102
+ .map(escapeCharacter)
1103
+ .join(', ');
1104
+ diagnostics.push({
1105
+ code: 'NONPRINTABLE_LABEL',
1106
+ severity: 'warning',
1107
+ message: `${counted(marked.length, 'annotation')} ${one ? 'has a description' : 'have descriptions'} ` +
1108
+ `carrying text a terminal does not print as itself (${shown}), written to ` +
1109
+ `annotations.csv exactly as the file has ${one ? 'it' : 'them'}.`,
1110
+ hint: 'A control byte can drive the terminal and a bidirectional override reverses what ' +
1111
+ 'follows it, so read the file with pandas or R rather than with cat. The cell is ' +
1112
+ 'what the recording says either way.',
1113
+ });
1114
+ }
1115
+ return diagnostics;
1116
+ }
1051
1117
  /**
1052
1118
  * The window annotations are filtered by — the bounds as asked for, not as snapped to records.
1053
1119
  *