import type { PlayAuthoringCsvOptions, PlayAuthoringCsvRenameMap, } from '../plays/authoring-contract'; export type CsvRenameMap = PlayAuthoringCsvRenameMap; export type CsvRenameOptions = Pick< PlayAuthoringCsvOptions, 'columns' | 'rename' | 'required' >; const CSV_PROJECTED_FIELDS = Symbol.for('deepline.play.csv.projected_fields'); const CSV_PROJECTED_FIELDS_KEY = '__deeplineCsvProjectedFields'; const CSV_PROJECTED_VALUES_KEY = '__deeplineCsvProjectedValues'; type ProjectedRow = Record & { [CSV_PROJECTED_FIELDS]?: ReadonlySet; [CSV_PROJECTED_FIELDS_KEY]?: readonly string[]; [CSV_PROJECTED_VALUES_KEY]?: Record; }; function normalizeCsvHeader(header: string): string { return header .trim() .replace(/([a-z0-9])([A-Z])/g, '$1_$2') .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, ''); } function readString(value: unknown): string { if (typeof value === 'string') return value.trim(); if (typeof value === 'number' && Number.isFinite(value)) return String(value); return ''; } function buildRowLookup(row: Record): Map { const lookup = new Map(); for (const [field, value] of Object.entries(row)) { lookup.set(field, value); lookup.set(normalizeCsvHeader(field), value); } return lookup; } function sourceAliases(target: string, aliases: string | readonly string[]) { return [target, ...(Array.isArray(aliases) ? aliases : [aliases])]; } /** * `required` is a HEADER-presence guarantee, not a per-row non-null check. * * A required target is satisfied when at least one of its source aliases exists * as a column in the CSV row (present in the header), regardless of whether this * particular row's cell is blank. Blank cells in a present column are DATA, not a * schema violation: they must flow through to the dataset so `onRowError` (or the * play's own branching) governs them per row, never guillotine the whole run. * Only a column that is entirely absent from the CSV is a `required` violation. */ function hasAliasColumnPresent( lookup: Map, target: string, sourceNames: string | readonly string[], ): boolean { for (const alias of sourceAliases(target, sourceNames)) { if (lookup.has(alias) || lookup.has(normalizeCsvHeader(alias))) { return true; } } return false; } export function applyCsvRenameProjection>( rows: readonly T[], options?: CsvRenameOptions, ): Array> { const aliases = { ...(options?.rename ?? {}), ...(options?.columns ?? {}), }; const required = new Set(options?.required ?? []); if (Object.keys(aliases).length === 0) { // No rename/alias projection requested. `required` is still a // header-presence guarantee: assert each required column exists in the CSV // (checked against the first row's shape), then pass rows through untouched. // A present-but-blank cell is data, never a `required` violation. if (required.size > 0 && rows.length > 0) { const lookup = buildRowLookup(rows[0]!); for (const target of required) { if (!hasAliasColumnPresent(lookup, target, target)) { throw new Error( `ctx.csv(..., { required }) is missing required column "${target}". ` + `Add a "${target}" column to the CSV header.`, ); } } } return [...rows]; } return rows.map((row, index) => { const lookup = buildRowLookup(row); const projectedFields = new Set(); const projectedValues: Record = {}; const projected: Record = { ...row }; for (const [target, sourceNames] of Object.entries(aliases)) { let selected: unknown; for (const alias of sourceAliases(target, sourceNames)) { const exact = lookup.get(alias); const normalized = lookup.get(normalizeCsvHeader(alias)); const candidate = exact ?? normalized; if (readString(candidate)) { selected = candidate; break; } } if (readString(selected)) { if (!Object.prototype.hasOwnProperty.call(row, target)) { Object.defineProperty(projected, target, { value: selected, enumerable: false, configurable: true, writable: true, }); projectedFields.add(target); projectedValues[target] = selected; } else { projected[target] = selected; } } else if ( required.has(target) && !hasAliasColumnPresent(lookup, target, sourceNames) ) { // The column is entirely absent from the CSV header — a schema/config // error that is identical for every row. A present-but-blank cell is // data and must NOT trip this: it flows through so onRowError can // isolate that row instead of failing the whole run. throw new Error( `ctx.csv(..., { required }) is missing required column "${target}". ` + `Add a "${target}" column (or one of its aliases) to the CSV header.`, ); } } Object.defineProperty(projected, CSV_PROJECTED_FIELDS, { value: projectedFields, enumerable: false, configurable: true, }); if (projectedFields.size > 0) { Object.defineProperty(projected, CSV_PROJECTED_FIELDS_KEY, { value: [...projectedFields], enumerable: true, configurable: true, writable: true, }); Object.defineProperty(projected, CSV_PROJECTED_VALUES_KEY, { value: projectedValues, enumerable: true, configurable: true, writable: true, }); } return projected as T & Record; }); } export function cloneCsvAliasedRow>( row: T, extra?: Record, ): Record { const cloned: Record = { ...row, ...(extra ?? {}) }; const projectedFields = getCsvProjectedFields(row); if (!projectedFields?.size) { return cloned; } const clonedProjectedFields = new Set(); const serializedValues = getCsvProjectedValues(row); for (const field of projectedFields) { if (Object.prototype.hasOwnProperty.call(cloned, field)) { continue; } const descriptor = Object.getOwnPropertyDescriptor(row, field); if (descriptor) { Object.defineProperty(cloned, field, { ...descriptor, enumerable: false, configurable: true, }); } else { Object.defineProperty(cloned, field, { value: serializedValues?.[field] ?? row[field], enumerable: false, configurable: true, writable: true, }); } clonedProjectedFields.add(field); } if (clonedProjectedFields.size > 0) { Object.defineProperty(cloned, CSV_PROJECTED_FIELDS, { value: clonedProjectedFields, enumerable: false, configurable: true, }); Object.defineProperty(cloned, CSV_PROJECTED_FIELDS_KEY, { value: [...clonedProjectedFields], enumerable: true, configurable: true, writable: true, }); Object.defineProperty(cloned, CSV_PROJECTED_VALUES_KEY, { value: Object.fromEntries( [...clonedProjectedFields].map((field) => [field, cloned[field]]), ), enumerable: true, configurable: true, writable: true, }); } return cloned; } /** * Plain enumerable clone for durable serialization (sheet writes, JSON * payloads). Projected alias fields are normally non-enumerable so they stay * out of spreads and Object.keys — but a JSON round-trip would silently drop * them. This materializes every projected alias as a visible field and drops * the internal `__deepline*` metadata keys. */ export function toSerializableCsvAliasedRow( row: Record, ): Record { const out: Record = {}; for (const [field, value] of Object.entries(row)) { if (field.startsWith('__deepline')) continue; out[field] = value; } const projectedFields = getCsvProjectedFields(row); if (projectedFields) { const serializedValues = getCsvProjectedValues(row); for (const field of projectedFields) { if (Object.prototype.hasOwnProperty.call(out, field)) continue; out[field] = row[field] ?? serializedValues?.[field]; } } return out; } export function stripCsvProjectedFields>( row: T, ): T { const projectedFields = getCsvProjectedFields(row); if (!projectedFields?.size) return row; const stripped = { ...row }; for (const field of projectedFields) { delete stripped[field]; } delete stripped[CSV_PROJECTED_FIELDS_KEY]; delete stripped[CSV_PROJECTED_VALUES_KEY]; return stripped as T; } export function getCsvProjectedFields( row: Record, ): ReadonlySet | null { const symbolFields = (row as ProjectedRow)[CSV_PROJECTED_FIELDS]; if (symbolFields?.size) return symbolFields; const serializedFields = (row as ProjectedRow)[CSV_PROJECTED_FIELDS_KEY]; if (!Array.isArray(serializedFields) || serializedFields.length === 0) { return null; } return new Set(serializedFields.filter((field) => typeof field === 'string')); } function getCsvProjectedValues( row: Record, ): Record | null { const serializedValues = (row as ProjectedRow)[CSV_PROJECTED_VALUES_KEY]; if ( !serializedValues || typeof serializedValues !== 'object' || Array.isArray(serializedValues) ) { return null; } return serializedValues; } export function stripCsvProjectionMetadata>( row: T, ): T { if ( !Object.prototype.hasOwnProperty.call(row, CSV_PROJECTED_FIELDS_KEY) && !Object.prototype.hasOwnProperty.call(row, CSV_PROJECTED_VALUES_KEY) ) { return row; } const stripped = cloneCsvAliasedRow(row) as T; delete stripped[CSV_PROJECTED_FIELDS_KEY]; delete stripped[CSV_PROJECTED_VALUES_KEY]; return stripped as T; }