@@ -72,6 +72,14 @@ const SEX_SUGGESTIONS: Readonly<Record<string, string>> = {
7272 hermaphrodite : 'O' ,
7373} ;
7474
75+ /** Known legacy YAML key spellings that differ only by spaces vs underscores. */
76+ const SPACE_KEY_ALIASES : Readonly < Record < string , string > > = {
77+ 'subject id' : 'subject_id' ,
78+ 'data acq device' : 'data_acq_device' ,
79+ 'electrode groups' : 'electrode_groups' ,
80+ 'ntrode electrode group channel map' : 'ntrode_electrode_group_channel_map' ,
81+ } ;
82+
7583/** How a repair item is resolved in the UI. */
7684export type RepairKind = 'suggestion' | 'input' ;
7785
@@ -205,6 +213,78 @@ interface VolumeShimItem {
205213 [ key : string ] : unknown ;
206214}
207215
216+ /**
217+ * Whether a decoded YAML value is a plain record that can carry schema keys.
218+ *
219+ * @param value - The value to check.
220+ * @returns True for mutable object records; false for arrays, null, Date, and scalars.
221+ */
222+ function isRecord ( value : unknown ) : value is Record < string , unknown > {
223+ return (
224+ value !== null &&
225+ typeof value === 'object' &&
226+ ! Array . isArray ( value ) &&
227+ ! ( value instanceof Date )
228+ ) ;
229+ }
230+
231+ /**
232+ * Convert a path segment list to a stable human-readable import-repair path.
233+ *
234+ * @param parts - The path parts.
235+ * @returns A dot path, or `(root)` for the document root.
236+ */
237+ function formatImportPath ( parts : ReadonlyArray < string | number > ) : string {
238+ if ( parts . length === 0 ) return '(root)' ;
239+ return parts . map ( ( part ) => String ( part ) ) . join ( '.' ) ;
240+ }
241+
242+ /**
243+ * Apply known legacy space-key aliases in place, returning the listed benign normalizations.
244+ * Only known schema-key aliases are rewritten; arbitrary user keys are preserved.
245+ *
246+ * @param value - The decoded model or nested value.
247+ * @param path - The current traversal path.
248+ * @returns The benign normalizations applied.
249+ */
250+ function applySpaceKeyAliases (
251+ value : unknown ,
252+ path : Array < string | number > = [ ]
253+ ) : BenignNormalization [ ] {
254+ const benign : BenignNormalization [ ] = [ ] ;
255+
256+ if ( Array . isArray ( value ) ) {
257+ value . forEach ( ( item , index ) => {
258+ benign . push ( ...applySpaceKeyAliases ( item , [ ...path , index ] ) ) ;
259+ } ) ;
260+ return benign ;
261+ }
262+
263+ if ( ! isRecord ( value ) ) return benign ;
264+
265+ for ( const [ legacyKey , canonicalKey ] of Object . entries ( SPACE_KEY_ALIASES ) ) {
266+ if ( ! Object . prototype . hasOwnProperty . call ( value , legacyKey ) ) continue ;
267+ if ( Object . prototype . hasOwnProperty . call ( value , canonicalKey ) ) continue ;
268+
269+ value [ canonicalKey ] = value [ legacyKey ] ;
270+ delete value [ legacyKey ] ;
271+ const parentPath = formatImportPath ( path ) ;
272+ benign . push ( {
273+ path : parentPath ,
274+ label : `${ legacyKey } → ${ canonicalKey } ` ,
275+ detail :
276+ `Renamed legacy key \`${ legacyKey } \` → \`${ canonicalKey } \` ` +
277+ `at ${ parentPath } — no values changed.` ,
278+ } ) ;
279+ }
280+
281+ for ( const [ key , child ] of Object . entries ( value ) ) {
282+ benign . push ( ...applySpaceKeyAliases ( child , [ ...path , key ] ) ) ;
283+ }
284+
285+ return benign ;
286+ }
287+
208288/**
209289 * Build the repair items + blockers for a model's `validate` errors. Known leaf codes map to
210290 * suggestions/inputs; everything else becomes a blocker (fix-in-file).
@@ -350,6 +430,8 @@ function buildBenignAndShim(model: ValidationModel): {
350430 const benign : BenignNormalization [ ] = [ ] ;
351431 const shimItems : RepairItem [ ] = [ ] ;
352432
433+ benign . push ( ...applySpaceKeyAliases ( structuredClone ( model ) ) ) ;
434+
353435 // --- task_epoch (singular) → task_epochs in file/video lists (the field the app reads). ---
354436 for ( const key of [ 'associated_files' , 'associated_video_files' ] as const ) {
355437 const list = ( model as Record < string , unknown > ) [ key ] ;
@@ -401,14 +483,17 @@ function buildBenignAndShim(model: ValidationModel): {
401483}
402484
403485/**
404- * Apply the benign, lossless normalizations to a (mutable) model: rename `task_epoch` → the
405- * `task_epochs` key the app reads, and fill a missing volume spelling from the present one. Shared
406- * by {@link buildImportRepairPlan} (which validates the NORMALIZED model, so a benign-fixable issue
407- * never also surfaces as a repair item) and {@link applyImportRepairs}.
486+ * Apply the benign, lossless normalizations to a (mutable) model: recover known legacy space-key
487+ * schema spellings, rename `task_epoch` → the `task_epochs` key the app reads, and fill a missing
488+ * volume spelling from the present one. Shared by {@link buildImportRepairPlan} (which validates
489+ * the NORMALIZED model, so a benign-fixable issue never also surfaces as a repair item) and
490+ * {@link applyImportRepairs}.
408491 *
409492 * @param model - The model to mutate in place.
410493 */
411494function applyBenignNormalizations ( model : Record < string , unknown > ) : void {
495+ applySpaceKeyAliases ( model ) ;
496+
412497 for ( const key of [ 'associated_files' , 'associated_video_files' ] as const ) {
413498 const list = model [ key ] ;
414499 if ( ! Array . isArray ( list ) ) continue ;
@@ -458,7 +543,7 @@ export function buildImportRepairPlan(
458543 const { items, blockers } = buildValidationItems ( normalized as ValidationModel ) ;
459544
460545 // Decision: match the subject id against the existing workspace (by key or subject.subject_id).
461- const subjectId = ( model . subject as { subject_id ?: unknown } | undefined ) ?. subject_id ;
546+ const subjectId = ( normalized . subject as { subject_id ?: unknown } | undefined ) ?. subject_id ;
462547 let decision : ImportDecision ;
463548 if ( typeof subjectId !== 'string' || subjectId . trim ( ) === '' ) {
464549 decision = { kind : 'blocked' , reason : 'The file has no subject_id, so it cannot be attributed to an animal.' } ;
@@ -482,8 +567,8 @@ export function buildImportRepairPlan(
482567/**
483568 * Apply the benign normalizations and the user's accepted resolutions to a model, returning a NEW
484569 * model (the input is never mutated, and no key is dropped). Resolutions are keyed by the same path
485- * the repair items carry; benign normalizations (task_epoch rename, single-spelling volume fill)
486- * are applied unconditionally because the screen lists them.
570+ * the repair items carry; benign normalizations (space-key aliases, task_epoch rename, single-spelling
571+ * volume fill) are applied unconditionally because the screen lists them.
487572 *
488573 * @param flatModel - The decoded flat model.
489574 * @param resolutions - Accepted/edited values keyed by repair-item path.
@@ -495,7 +580,7 @@ export function applyImportRepairs(
495580) : Record < string , unknown > {
496581 const model = structuredClone ( ( flatModel ?? { } ) as Record < string , unknown > ) ;
497582
498- // Benign, lossless normalizations (task_epoch rename, single-spelling volume fill).
583+ // Benign, lossless normalizations (space-key aliases, task_epoch rename, single-spelling volume fill).
499584 applyBenignNormalizations ( model ) ;
500585
501586 // Accepted/edited resolutions, applied at their paths. A reconciled volume sets BOTH spellings
0 commit comments