import { isClassMarker } from './classify' const DELIMITER_CANDIDATES = ['_', '-', ' ', '.'] as const export type Delimiter = (typeof DELIMITER_CANDIDATES)[number] /** Pick the delimiter whose split produces the most consistent field count across `lines`. */ export function pickPrimaryDelimiter(lines: string[]): Delimiter { if (lines.length === 0) return '_' let best: Delimiter = '_' let bestScore = -1 for (const candidate of DELIMITER_CANDIDATES) { const counts = lines.map(l => l.split(candidate).length) const frequency = new Map() for (const c of counts) frequency.set(c, (frequency.get(c) ?? 0) + 1) // Mode and its frequency let mode = 1 let modeFreq = 0 for (const [c, f] of frequency) { if (f > modeFreq || (f === modeFreq && c > mode)) { mode = c modeFreq = f } } // A delimiter that produces field count 1 didn't split anything — score 0. const score = mode > 1 ? modeFreq / lines.length : 0 if (score > bestScore) { best = candidate bestScore = score } } return best } /** Split a single sample name on `delimiter`. Empty input returns `['']` so callers * always receive at least one token. */ export function splitMulti(name: string, delimiter: Delimiter): string[] { return name.split(delimiter) } /** The `exp__` fields at the start of a sample name. */ export interface ExperimentHeader { code?: string /** ISO date. Names carry it as YYMMDD, YYYYMMDD or DDMMYYYY. */ date?: string operator?: string } export interface TokenizedBatch { delimiter: Delimiter /** One row of tokens for each input name, in input order. */ rows: string[][] /** Header fields with one value in the batch. They are not in `rows`. */ experiment: ExperimentHeader /** * Number of leading tokens that all rows share (normally the experiment * title). They cannot tell a QC sample from a study sample. */ sharedPrefix: number } const HEADER_FIELDS = ['code', 'date', 'operator'] as const // Share of the names with a header that must carry the same operator before it // leaves the rows. The rest allows for stray names such as `exp157_test_blank_1`. const OPERATOR_AGREEMENT = 0.9 // Outside this window a 6-digit token is read day-first: `091123` is 2023-11-09, not 2009. const FIRST_PLAUSIBLE_YEAR = 2015 function isoDate(year: number, month: number, day: number): string | undefined { const lastYear = new Date().getFullYear() + 1 if (year < FIRST_PLAUSIBLE_YEAR || year > lastYear) return undefined // `Date` rolls an impossible day over to the next month; reject that. const date = new Date(Date.UTC(year, month - 1, day)) if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return undefined return date.toISOString().slice(0, 10) } /** Read a 6- or 8-digit date token; year-first is tried before day-first. */ export function parseDateToken(token: string): string | undefined { const n = (from: number, to: number): number => Number(token.slice(from, to)) if (/^\d{8}$/.test(token)) return isoDate(n(0, 4), n(4, 6), n(6, 8)) ?? isoDate(n(4, 8), n(2, 4), n(0, 2)) if (/^\d{6}$/.test(token)) return isoDate(2000 + n(0, 2), n(2, 4), n(4, 6)) ?? isoDate(2000 + n(4, 6), n(2, 4), n(0, 2)) return undefined } type HeaderField = (typeof HEADER_FIELDS)[number] /** * Read the experiment code, date and operator from the left of a name, in any * order. `position` holds the token index of each field. A name with no code * and no date has no header, so `WT_Ctrl_1` keeps `WT` as a sample token. */ function readHeader(tokens: string[]): { fields: ExperimentHeader; position: Partial> } { const fields: ExperimentHeader = {} const position: Partial> = {} let i = 0 if (tokens.length > 1 && /^exp\d+$/i.test(tokens[0])) { fields.code = tokens[0].toLowerCase() position.code = i++ } for (; i < tokens.length - 1; i++) { const date = fields.date ? undefined : parseDateToken(tokens[i]) if (date) { fields.date = date position.date = i } else if (!fields.operator && /^[A-Z]{2,3}$/.test(tokens[i]) && !isClassMarker(tokens[i])) { fields.operator = tokens[i] position.operator = i } else { break } } if (!fields.code && !fields.date) return { fields: {}, position: {} } return { fields, position } } const MS_FILE_EXTENSION = /\.(raw|mzml|mzxml|mzdata|wiff2?|d|mgf|csv|tsv|txt)$/i /** Drop a mass-spec file extension from each name, and any other extension that all names share. */ function stripExtensions(names: string[]): string[] { const bare = names.map(name => name.replace(MS_FILE_EXTENSION, '')) const ext = /\.[A-Za-z][A-Za-z0-9]{0,4}$/.exec(bare[0] ?? '')?.[0] return ext && bare.every(name => name.endsWith(ext)) ? bare.map(name => name.slice(0, -ext.length)) : bare } /** * Sample names → token rows. * * 1. Trim; drop a file extension that all names share. * 2. Split on runs of the primary delimiter. * 3. Drop a trailing 14-digit acquisition stamp. * 4. Read the experiment header. A field moves to `experiment` when the batch * has one value for it. Any other field stays in the rows as written, so * `QC` or `WT` after the date in some names is not taken for operator * initials. * 5. Count the tokens that all names share after the header (the experiment * title), so a title word such as `std` cannot classify the batch as QC. */ export function tokenizeBatch(names: string[]): TokenizedBatch { const cleaned = stripExtensions(names.map(name => name.trim())) const delimiter = pickPrimaryDelimiter(cleaned) const parsed = cleaned.map(name => { const split = name.split(delimiter).filter(Boolean) if (split.length > 1 && /^\d{14}$/.test(split[split.length - 1])) split.pop() const tokens = split.length > 0 ? split : [name] return { tokens, ...readHeader(tokens) } }) const experiment: ExperimentHeader = {} for (const field of ['code', 'date'] as const) { const values = new Set(parsed.map(({ fields }) => fields[field]).filter(Boolean)) if (values.size === 1) experiment[field] = [...values][0] } // `exp` and a date are clear by their form. Operator initials are not: // `QC`, `WT` or `KO` look the same. The operator is the caps token that // (almost) all complete headers carry; any other caps token stays in its row. const dated = parsed.filter(({ fields }) => fields.date) const complete = dated.length > 0 ? dated : parsed.filter(({ fields }) => fields.code) const operatorCounts = new Map() for (const { fields } of complete) { if (fields.operator) operatorCounts.set(fields.operator, (operatorCounts.get(fields.operator) ?? 0) + 1) } const [operator, count] = [...operatorCounts].sort((a, b) => b[1] - a[1])[0] ?? [] if (operator && count >= complete.length * OPERATOR_AGREEMENT) experiment.operator = operator const rows = parsed.map(({ tokens, fields, position }) => { const moved = new Set( HEADER_FIELDS.filter(field => experiment[field] && fields[field] === experiment[field]).map(field => position[field]), ) return tokens.filter((_, index) => !moved.has(index)) }) // A title exists only after a header; without one a shared first token can // be the QC marker of a QC-only paste. let sharedPrefix = 0 if (rows.length > 1 && Object.keys(experiment).length > 0) { while (rows.every(row => row.length > sharedPrefix + 1 && row[sharedPrefix] === rows[0][sharedPrefix])) sharedPrefix++ // When only numbers follow, the shared tokens are the sample description // (a QC-only batch: `QC_1`, `QC_2`), not a title. if (rows.every(row => row.slice(sharedPrefix).every(token => /^\d+[A-Za-z]?$/.test(token)))) sharedPrefix = 0 } return { delimiter, experiment, rows, sharedPrefix } }