/** * Executing a conversion. * * Everything is written in a single pass over the data records. All rate groups are * open at once and fed from the same batch of bytes, so a file is read once no * matter how many output tables it produces, and memory stays flat. */ import { EdfFile } from '../edf/reader.js'; import type { Diagnostic } from '../edf/errors.js'; import type { Annotation } from '../edf/annotations.js'; import type { ConversionPlan, PlanOptions } from './plan.js'; import { VERSION as TOOL_VERSION } from '../version.js'; export { TOOL_VERSION }; export type ConversionErrorCode = 'OUTPUT_EXISTS' | 'OUTPUT_UNWRITABLE' | 'INPUT_OUTPUT_COLLISION' | 'INPUT_UNREADABLE' | 'UNSUPPORTED_REQUEST' | 'CALLBACK_FAILED' | 'WRITE_FAILED'; /** * Codes that mean the command cannot be carried out as written, rather than that something * about the file or the destination went wrong. * * The distinction is the one the exit codes draw: 1 is "the file or the destination is the * problem", 2 is "the command line is the problem". A caller with a `--stdout` conflict is * being told to change the flags — the hints say exactly that — so filing it under 1 sent * scripts looking at the disk. Exit 2 already covers checks that need the header first, * such as a `--channels` term matching nothing. */ export declare const USAGE_ERROR_CODES: ReadonlySet; export declare class ConversionError extends Error { readonly code: ConversionErrorCode; readonly hint: string | undefined; constructor(code: ConversionErrorCode, message: string, hint?: string, options?: ErrorOptions); } export interface ConvertOptions extends PlanOptions { /** * Destination directory. Defaults to `defaultOutputDir(inputPath)`: the input's name with * its extension replaced by `_csv`, beside the input — `sleep-study.edf` gives * `sleep-study_csv`, not `sleep-study`. */ outputDir?: string | undefined; /** Overwrite an existing output directory. */ force?: boolean | undefined; /** Record a SHA-256 of the input in metadata.json. Costs one extra read of the file. */ checksum?: boolean | undefined; /** * Write the signal CSV to stdout instead of to a directory. * * Only valid when the conversion produces exactly one signal file. In the default wide * layout a mixed-rate recording becomes several tables, and merging them into one stream * would mean inventing the samples this tool exists not to invent; `layout: 'long'` gives * one table for any recording, so it lifts the restriction. No sidecar files are written. */ toStdout?: boolean | undefined; onProgress?: ((progress: ConversionProgress) => void) | undefined; } export interface ConversionProgress { recordsDone: number; recordsTotal: number; bytesWritten: number; } export interface WrittenFile { name: string; rows: number; } export interface ConvertResult { outputDir: string; files: WrittenFile[]; /** * True when a `--stdout` reader closed the pipe before the conversion finished. * * `edf2csv rec.edf --stdout | head -1` is an ordinary thing to type and not a failure, but * it is also not a conversion: the row count is rows formatted before the close was * noticed, which is neither the recording's total nor what the reader received. */ readerHungUp: boolean; annotationCount: number; diagnostics: Diagnostic[]; plan: ConversionPlan; file: EdfFile; elapsedMs: number; } export declare function convert(inputPath: string, options?: ConvertOptions): Promise; export declare function defaultOutputDir(inputPath: string): string; /** * Turn a Node filesystem error into something a person can act on: an errno as a sentence, * so Node's own text never reaches the screen. * * Exported for the CLI's last-resort stdout listener, which is the third and last place a * write failure becomes a message and the one that was still printing `ENOSPC: no space left * on device, write`. */ export declare function describeFsError(cause: unknown): string; /** * What the durations in the events that will actually be written look like. * * These two warnings were raised from the file-wide counts the decoder accumulates, while * `annotations.csv` is filtered to the requested window. A conversion of one second of a * recording therefore warned that "1 annotation states a duration that is not a number, so * its duration_s cell is empty" about an event two seconds outside it — naming a cell that is * not in the output — and `--strict` failed the run for it. There is no such value, no such * cell, and no such row. * * Taken from the events themselves, after the same filter the writer applies, so the count * and the sentence describe the same rows. An unreadable duration is carried on the event * because `duration: null` cannot say whether the file gave one; a negative duration needs no * flag, since the value is right there. */ export declare function durationDiagnostics(annotations: readonly Annotation[], window: { from: number; to: number; }, gzip?: boolean): Diagnostic[]; /** * What the descriptions in the events that will actually be written look like. * * EDF's four free-text header fields have had two warnings about where they land since they * were written: `FORMULA_LABEL` for text a spreadsheet runs instead of reading, and * `NONPRINTABLE_LABEL` for bytes that drive a terminal. Both say the same thing about the * remedy — the text is written exactly as the file has it, because rewriting it would mean * the CSV no longer says what the recording says — and both exist so that the tool is not * silent about where it goes. * * `annotations.csv`'s `description` column is the same kind of text, out of the same file, * into the same spreadsheet, and nothing was said about it at all. An event described * `=HYPERLINK("http://…","Sleep stage W")` was written verbatim, exit 0, no warning, and * opens as a live link nobody in the reading chain wrote; one carrying `\x1b[31m` turns the * terminal red on `cat annotations.csv`. It is the more likely of the two to happen by * accident, since a description is typed by a person at a scoring station while a channel * label is written once by the recorder. * * It is also the only free text in the output that can carry a character above U+00FF — * header text is decoded latin1, so every byte of it becomes a code point below U+0100, and * a bidirectional override cannot reach a label. It can reach a description, which is UTF-8. * * Counted rather than raised per event, unlike the header's four fields: a night's scoring is * thousands of events, and a warning each is not a report. The count is of the rows that * reach `annotations.csv`, after the same window filter the writer applies, for the reason * `durationDiagnostics` beside it gives. */ export declare function descriptionDiagnostics(annotations: readonly Annotation[], window: { from: number; to: number; }, /** The name the run writes the event list under; see `durationDiagnostics`. */ gzip?: boolean): Diagnostic[]; /** * The window annotations are filtered by — the bounds as asked for, not as snapped to records. * * Exported so `--info` can count the events a conversion would write using the same predicate * that writes them, rather than a second copy of it. */ export declare function requestedAnnotationWindow(options: ConvertOptions, recordingStart: number): { from: number; to: number; }; /** * Confirms that everything handed to a file-backed stdout actually arrived. * * `edf2csv rec.edf --stdout > out.csv` onto a volume that the output very nearly fills lost * the tail in silence: 94,977 of 102,400 rows on disk, the file ending mid-row, stderr * announcing "Wrote 102,400 rows to stdout." and the process exiting 0. The same recording * onto the same volume through `--out` fails correctly, which is what gives it away. * * POSIX `write` returns a short count rather than an error when the disk fills partway * through a single call, and only the NEXT write raises ENOSPC. `--out` always has a next * write — channels.csv and metadata.json come after — so it always finds out. `--stdout` * has nothing after it, and when fd 1 is a regular file Node's stdout is a SyncWriteStream * whose `_write` discards the byte count `writeSync` returns, so nothing is raised at all. * No error means no `#failure`, so checking that alone would not have caught this. * * What can be checked is the descriptor: how much it grew against how much it was given. * Only for a regular file — a pipe, a terminal or a socket has no size to compare, and on * those a short write cannot go unreported this way. Appending (`>>`) is fine, since the * starting size is taken before anything is written. * * Exported so `--info` can use the same audit a `--stdout` conversion does. * * `--info` wrote its description with `process.stdout.write` and looked at nothing: redirected * into a full filesystem it produced a zero-byte file and exited 0, so `edf2csv rec.edf --info * > desc.txt` reported success over nothing at all. A 900-channel recording's description is * 58 KB, which is not a size a destination is guaranteed to have. */ export declare function auditStdout(): { count: (bytes: number) => void; verify: () => void; } | null; /** * Why `--stdout` cannot take this recording, or null when it can. * * Lifted out of the conversion so `--info` can ask the same question. It was not asking: * `--info --stdout` on a three-rate recording predicted "Would write 1,155 rows, roughly * 22.2 KB" and said the channels "are written to one file per rate" — for a command that * refuses to run, writes nothing, and names no files. `--info` exists to say what a * conversion will do, and this is one of the things it does. * * Reported by `--info` as a warning rather than a refusal, for the reason 0.5.51 gives about * the destination guards: `--info` writes nothing, so a rule about what the output would be * has no business stopping it from describing the recording — and being told the command * will not work is exactly what you asked. */ export declare function stdoutRefusal(file: EdfFile, plan: ConversionPlan): ConversionError | null; /** * Asked for signal data and given none to put in a file. * * Every channel selected carries zero samples per record, so there is no table to make — * `edf2csv rec.edf --channels unused` writes channels.csv and metadata.json and no signals.csv * at all. The NO_SAMPLES warning explains the channel; nothing explained the missing file, and * the documentation says signals.csv is written unless --annotations-only was passed. Someone * looking for it should be told where it went. * * There are two ways to arrive with no groups, and one wording is only true of one of them. A * recording that holds nothing but EDF+ annotations has no channel that could have been * selected, its channels.csv is a header row and nothing else, and no channel of it carries * samples — so "every channel selected", "channels.csv still describes them" and "which * channels do carry samples" were three false statements in one warning, printed under a * warning that had just said the file has no signal channels. * * Exported, and worded in the present tense, so `--info` can raise the same one. It was built * inline here, which meant the one mode whose purpose is to say what a conversion will do said * nothing about the file that conversion would not write: `--info --strict` on a recording of * nothing but annotations reported one warning where converting it reported two, and * `--info --json` carried the shorter list to whatever reads it. Everything the answer depends * on is in the file and the plan, both of which `--info` already has. */ export declare function noSignalFile(file: EdfFile, plan: ConversionPlan): Diagnostic | null; /** * The channels whose samples this run actually writes. * * `plan.columnNames` names every channel of the recording, because the names are derived from * the whole file rather than from the selection — so it is not the answer. The groups are: * `--channels` builds them out of what was selected, and a channel with no samples per record * has none to put in one either. */ export declare function convertedChannels(plan: ConversionPlan): Set; /** * The channels whose rate the window holds no samples of, while the run has rows elsewhere. * * The third way a channel ends up with no cells, after `--annotations-only` and the channels * `--channels` leaves out. A rate group is what gets a file, so a window a hundredth of a * second wide leaves a 1 Hz channel's file holding its header while a 256 Hz channel in the * same recording keeps three rows — and "Its cells carry that value" was as untrue of the * first as it is of a channel nothing converts at all. * * Empty for a run with no rows anywhere, which is `EMPTY_WINDOW`'s case and already answered * with its own wording, and for the long layout, where every rate shares one table. */ export declare function channelsWithoutRows(plan: ConversionPlan): Set; /** * The signal table an `--annotations-only` run does not write, taken out of the hints about it. * * That mode writes the event list and nothing else — no signal files at all — and four hints * about record timing describe the rows of one. On a recording whose records run backwards it * printed both of these over an annotations.csv holding its header and no rows: * * warning: This is a discontinuous (EDF+D) recording: its data records are not * contiguous in time. * Each row carries its true recording time, so gaps stay visible instead of * being closed. * warning: 2 data records start earlier than the record before them. * Rows are written in file order, so the time column will not increase * monotonically. * * There are no rows and no time column. The facts above the hints are about the recording and * stay; what changes is the sentence describing what the conversion will do with them, which * is the same surgery `withTimingPromiseKept` does to the first of these when the record * starts cannot be derived. * * `writesSignals` rather than the option, because the plan is what settles it. */ export declare function withSignalTableUnwritten(diagnostics: readonly Diagnostic[], writesSignals: boolean, gzip: boolean, converted?: ReadonlySet, toStdout?: boolean, longLayout?: boolean, /** The channels this run writes no rows for; see `channelsWithoutRows`. */ emptiedByWindow?: ReadonlySet): Diagnostic[]; /** * The sidecar files a `--stdout` run does not write, taken out of the sentences about them. * * `--stdout` puts one table on the stream and writes nothing else — "No sidecar files are * written", as `ConvertOptions` puts it. Two diagnostics raised before the destination is * known end by pointing at one of those files: * * warning: The header's start date and time ("XX.XX.XX" and "YY.YY.YY") are not a date * and a time, so the recording has no start instant. * ... and metadata.json records start_datetime_local as null. * * warning: This recording's timekeeping annotations place it 1e17s from its own start * date ... * ... Add the onsets in annotations.csv to recover absolute times. * * Neither file exists after such a run. The second is advice a reader can follow into an * empty directory — there is no directory. * * Amended where the answer is, the same way `withTimingPromiseKept` rewrites a `DISCONTINUOUS` * hint the parser could not have known was false, and `withoutFileRateWarning` drops a header * diagnostic the plan supersedes. A conversion to a directory keeps every word. */ export declare function withSidecarsNamed(diagnostics: readonly Diagnostic[], { toStdout, gzip }: { toStdout: boolean; gzip: boolean; }): Diagnostic[]; /** * `--annotations-only` on a recording that has no annotations. * * Exported and present-tense for the same reason `noSignalFile` above is. `--info` prints an * accurate line about it in the report body — "and no annotations.csv either, since this * recording has no annotation channel" — and raised nothing, so the one mode whose purpose is * to say what a conversion will do carried a shorter warning list than the conversion did: * `--info --json --annotations-only` on a plain EDF file listed no warning where converting * lists one, and `--info --strict`, which cli-reference.md recommends for screening a folder * before converting it, exited 0 where the conversion exits 1. Same defect as the one 0.7.84 * closed for `NO_SAMPLES`, one flag over; both halves of the answer are in the file and the * options, which `--info` already has. */ export declare function noAnnotations(file: EdfFile, options: ConvertOptions): Diagnostic | null; /** * `--annotations-only` that wrote no events, and why. * * The two causes are told apart because the answers are different. A channel holding nothing * but timekeeping entries has nothing to export and never will; a window that excluded every * event is a thing the caller can change, and the commonest reason is reading the window off * a clock the recording does not use — `--start` and `--end` are on the recording's own, which * starts at zero unless `--info` shows a "Timed from" line. * * `NO_ANNOTATIONS` rather than a new code, since it is the same statement its other raising * makes — there are no events to export — about the same flag, and a code is matched on by * scripts that should not have to learn a second one for the same fact. */ export declare function emptyAnnotations(total: number, window: { from: number; to: number; }, gzip?: boolean): Diagnostic;