import type { ClassSchema, ColumnInfo, SampleClass } from '../../types/auto-group' import { classKey } from './classKey' export interface TemplateOptions { mode: 'blank' | 'prefilled' format: 'csv' | 'tsv' /** For prefilled mode. Indexed parallel to `samples`. */ tokenizedSamples?: string[][] /** Used to compute the filename; omitted → falls back to `sample_metadata_`. */ experimentCode?: string /** Override the blank-mode suggested headers. Defaults to ['group','class','replicate','notes']. */ suggestedColumns?: string[] } const DEFAULT_BLANK_HEADERS = ['group', 'class', 'replicate', 'notes'] function rfc4180Quote(cell: string): string { if (/[",\n]/.test(cell)) { return `"${cell.replace(/"/g, '""')}"` } return cell } function joinRow(cells: string[], sep: string): string { return sep === ',' ? cells.map(rfc4180Quote).join(',') : cells.join('\t') } function today(): string { return new Date().toISOString().slice(0, 10) } export function composeTemplate( samples: string[], schemas: ClassSchema[] | null, classes: SampleClass[] | null, options: TemplateOptions, ): { content: string; filename: string } { const sep = options.format === 'tsv' ? '\t' : ',' const ext = options.format === 'tsv' ? 'tsv' : 'csv' const stem = options.experimentCode ? `${options.experimentCode}_metadata` : 'sample_metadata' const filename = `${stem}_${today()}.${ext}` if (options.mode === 'blank' || !schemas || !classes) { const headers = ['sample_name', ...(options.suggestedColumns ?? DEFAULT_BLANK_HEADERS)] const rows = samples.map(s => { const r = [s, ...Array(headers.length - 1).fill('')] return joinRow(r, sep) }) return { content: [joinRow(headers, sep), ...rows].join('\n'), filename } } // Prefilled mode: union of all class columns, wide layout. const tokenized = options.tokenizedSamples ?? [] const allColumns: { schemaKey: string; colName: string }[] = [] // Hoist: build lookup maps before the per-sample loop. const sampleToClass = new Map() for (const cls of classes) for (const m of cls.members) sampleToClass.set(m, cls) const schemaByKey = new Map() for (const s of schemas) { const k = classKey({ kind: s.classKind, subKind: s.subKind, tokenLength: s.tokenLength }) schemaByKey.set(k, s) } const colByNameByKey = new Map>() const groupBySetByKey = new Map>() for (const s of schemas) { const k = classKey({ kind: s.classKind, subKind: s.subKind, tokenLength: s.tokenLength }) const m = new Map() for (const c of s.columns) m.set(c.displayName ?? c.name, c) colByNameByKey.set(k, m) groupBySetByKey.set(k, new Set(s.groupBy)) // Collect allColumns with dedup via Set const seenColNames = new Set() for (const c of s.columns) { const name = c.displayName ?? c.name if (!seenColNames.has(name)) { seenColNames.add(name) if (!allColumns.some(x => x.colName === name)) { allColumns.push({ schemaKey: k, colName: name }) } } } } const headers = ['sample_name', 'class', ...allColumns.map(c => c.colName), 'group', 'notes'] const rows: string[] = [] for (let i = 0; i < samples.length; i++) { const cls = sampleToClass.get(i) const classLabel = cls?.label ?? '' const tokens = tokenized[i] ?? [] const cells: string[] = [samples[i], classLabel] if (cls) { const k = classKey(cls) const groupBySet = groupBySetByKey.get(k) const colByName: Map | undefined = colByNameByKey.get(k) const keyParts: string[] = [] for (const col of allColumns) { if (col.schemaKey === k && colByName) { const schemaCol = colByName.get(col.colName) const raw = schemaCol?.sourceIndices.map(idx => tokens[idx] ?? '').join('_') ?? '' cells.push(raw) if (schemaCol && groupBySet?.has(schemaCol.index)) keyParts.push(raw) } else { cells.push('') } } cells.push(keyParts.join(' / ')) } else { for (const _ of allColumns) cells.push('') cells.push('') } cells.push('') // notes rows.push(joinRow(cells, sep)) } return { content: [joinRow(headers, sep), ...rows].join('\n'), filename } }