Setting the file. One moment.
Mapping Resolve · Rp Mapper · wix/skills · Skills Docs
ContentsBack to the top of the page lib/ mapping-resolve.js
JavaScript · 217 lines · 9 KB
15
// - which overlay entries reference a column not present -> `overlayDrift`
16 //
17 // The agent's job collapses to the `residue` object. Everything else is computed, repeatable, and
18 // diffable between runs — which is the point: an identical export must not produce a differently
19 // worded mapping plan just because a model sampled differently.
20 //
21 // The overlay stays ADVISORY in the sense that matters: the authoritative column list is the header
22 // row read at discovery time. An overlay can only claim columns that actually exist, and a column
23 // the overlay does not know is surfaced, never dropped.
24
25 const path = require ( 'path' );
26 const fs = require ( 'fs' );
27 const { CANONICAL_FIELDS , TARGETS , validateCanonicalPath } = require ( '../../rp-target-wix/lib/wix-target-spec.js' );
28
29 const VENDORS_DIR = path. join (__dirname, '..' , '..' , 'rp-source-csv' , 'vendors' );
30
31 // Column identity is the NORMALIZED name: lowercase, NFKC, punctuation and whitespace stripped.
32 // `Body (HTML)` -> `bodyhtml`, and `Option1 Name` === `Option 1 Name`. This absorbs vendor casing
33 // and punctuation churn without a code change.
34 function normalizeColumnName ( name ) {
35 return String (name == null ? '' : name)
36 . normalize ( 'NFKC' )
37 . toLowerCase ()
38 . replace ( / [ ^ a-z0-9] + / g , '' );
39 }
40
41 function loadVendorOverlay ( vendor ) {
42 const file = path. join ( VENDORS_DIR , `${ vendor }.json` );
43 if ( ! fs. existsSync (file)) return null ;
44 return JSON . parse (fs. readFileSync (file, 'utf8' ));
45 }
46
47 function listVendors () {
48 return fs
49 . readdirSync ( VENDORS_DIR )
50 . filter (( f ) => f. endsWith ( '.json' ))
51 . map (( f ) => f. replace ( / \. json $ / , '' ));
52 }
53
54 /**
55 * Resolve a mapping deterministically.
56 *
57 * @param {object} input
58 * @param {string[]} input.headers the header row AS READ at discovery time (authoritative)
59 * @param {string} input.vendor detected vendor, or 'custom' for no overlay
60 * @param {object} [input.overlay] pre-loaded overlay (tests inject; otherwise loaded by vendor)
61 * @param {string[]} [input.targets] which target entities to check coverage for
62 */
63 function resolveMapping ({ headers , vendor , overlay , targets = [ 'product' , 'category' ] }) {
64 const overlayDoc = overlay !== undefined ? overlay : loadVendorOverlay (vendor);
65 const headerByNormalized = new Map ();
66 const duplicateColumns = [];
67 for ( const header of headers || []) {
68 const key = normalizeColumnName (header);
69 if ( ! key) continue ;
70 if (headerByNormalized. has (key)) duplicateColumns. push (header);
71 else headerByNormalized. set (key, header);
72 }
73
74 const fieldMappings = [];
75 const claimedColumns = new Set ();
76 const overlayDrift = [];
77 const canonicalErrors = [];
78
79 for ( const entry of (overlayDoc && overlayDoc.columnMap) || []) {
80 if ( ! validateCanonicalPath (entry.wixTarget)) {
81 // A typo or an unmodelled concept in the overlay. Fail loudly at resolve time rather than
82 // silently producing a mapping that targets a field nothing can consume.
83 canonicalErrors. push ({ wixTarget: entry.wixTarget, reason: 'not a known canonical field' });
84 continue ;
85 }
86 // Aliases are ordered by preference; first present alias wins, and which one matched is
87 // recorded so a reviewer can see WHY a column was claimed.
88 let matched = null ;
89 for ( const alias of entry.aliases || []) {
90 const key = normalizeColumnName (alias);
91 if (headerByNormalized. has (key)) {
92 matched = { column: headerByNormalized. get (key), alias };
93 break ;
94 }
95 }
96 if ( ! matched) {
97 overlayDrift. push ({ wixTarget: entry.wixTarget, aliasesTried: entry.aliases || [], reason: 'no alias present in header' });
98 continue ;
99 }
100 claimedColumns. add ( normalizeColumnName (matched.column));
101 const def = CANONICAL_FIELDS [entry.wixTarget];
102 fieldMappings. push ({
103 sourceColumn: matched.column,
104 matchedAlias: matched.alias,
105 canonicalField: entry.wixTarget,
106 entity: def.entity,
107 kind: def.kind,
108 required: Boolean (def.required),
109 decisionProvenance: 'source_platform_rule' ,
110 });
111 }
112
113 // --- derived entities ----------------------------------------------------
114 // A `derived` entry synthesizes an entity from the DISTINCT VALUES of one column — this is how
115 // categories and tags arrive in every named vendor's export. Such an entity's name/path is fed
116 // by derivation, not by a column mapping, so crediting it here is what keeps `category.name`
117 // from being reported as an unfilled required field on a perfectly complete export.
118 const derivedEntities = [];
119 for ( const entry of (overlayDoc && overlayDoc.derived) || []) {
120 const key = normalizeColumnName (entry.fromColumn);
121 const present = headerByNormalized. has (key);
122 if (present) claimedColumns. add (key);
123 derivedEntities. push ({
124 entity: entry.entity,
125 fromColumn: present ? headerByNormalized. get (key) : entry.fromColumn,
126 present,
127 hierarchical: Boolean (entry.hierarchical),
128 linkPolicy: entry.linkPolicy || null ,
129 // A hierarchical source taxonomy mapped to a flat target triggers rp-mapper's mandatory
130 // faithfulness-ledger entry; surfacing the flag here is what makes that data-driven.
131 requiresFaithfulnessLedgerEntry: Boolean (entry.hierarchical),
132 fills: present ? [ `${ entry . entity }.name` , `${ entry . entity }.path` ]. filter (validateCanonicalPath) : [],
133 });
134 }
135 const derivedFilled = new Set (derivedEntities. flatMap (( d ) => d.fills));
136
137 // --- coverage against the Wix targets ------------------------------------
138 const unsupportedTargets = [];
139 const supportedByTarget = new Map ();
140 for ( const targetName of targets) {
141 const target = TARGETS [targetName];
142 if ( ! target) continue ;
143 supportedByTarget. set (targetName, new Set (Object. keys (target.fields)));
144 }
145 for ( const mapping of fieldMappings) {
146 const target = TARGETS [mapping.entity === 'variant' ? 'product' : mapping.entity];
147 if ( ! target) continue ;
148 if (target.fields[mapping.canonicalField]) continue ;
149 const reason = target.unsupported && target.unsupported[mapping.canonicalField];
150 unsupportedTargets. push ({
151 sourceColumn: mapping.sourceColumn,
152 canonicalField: mapping.canonicalField,
153 reason: reason || 'no Wix target field is declared for this canonical field' ,
154 declared: Boolean (reason),
155 });
156 }
157
158 // Required canonical inputs that nothing feeds. These are hard blockers, not review items.
159 const mappedFields = new Set (fieldMappings. map (( m ) => m.canonicalField));
160 const unfilledRequired = Object. entries ( CANONICAL_FIELDS )
161 . filter (([ fieldPath , def ]) => {
162 if ( ! def.required || mappedFields. has (fieldPath) || derivedFilled. has (fieldPath)) return false ;
163 const target = TARGETS [def.entity === 'variant' ? 'product' : def.entity];
164 return Boolean (target && target.fields[fieldPath]);
165 })
166 . map (([ fieldPath ]) => fieldPath);
167
168 // Source columns nothing claimed. THIS is the agent's queue.
169 const unmappedColumns = [ ... headerByNormalized. entries ()]
170 . filter (([ key ]) => ! claimedColumns. has (key))
171 . map (([, original ]) => original);
172
173 const totalColumns = headerByNormalized.size;
174 return {
175 vendor: vendor || 'custom' ,
176 overlayVersion: (overlayDoc && overlayDoc.profileVersion) || null ,
177 overlayPresent: Boolean (overlayDoc),
178 fieldMappings,
179 coverage: {
180 totalColumns,
181 mappedColumns: claimedColumns.size,
182 unmappedColumns: unmappedColumns. length ,
183 // The honest headline number: of the columns the overlay claimed, how many actually land in
184 // Wix. A column mapped to an unsupported canonical field is NOT coverage.
185 landingInWix: fieldMappings. length - unsupportedTargets. length ,
186 ratio: totalColumns ? Number ((claimedColumns.size / totalColumns). toFixed ( 4 )) : 0 ,
187 },
188 derivedEntities,
189 residue: { unmappedColumns, unsupportedTargets, unfilledRequired },
190 overlayDrift,
191 canonicalErrors,
192 duplicateColumns,
193 };
194 }
195
196 // Applies a resolved mapping to one raw source row -> a flat canonical field bag.
197 // Deliberately NOT the whole record assembler: grouping rows into products, deriving categories
198 // from a column's values, and unit conversion are adapter concerns (rp-source-csv owns them).
199 // This is the field-level half, and it is the half that was being hand-written per project.
200 function applyMapping ( row , resolved ) {
201 const out = {};
202 for ( const mapping of resolved.fieldMappings) {
203 const raw = row[mapping.sourceColumn];
204 if (raw === undefined ) continue ;
205 out[mapping.canonicalField] = raw;
206 }
207 return out;
208 }
209
210 module . exports = {
211 normalizeColumnName,
212 loadVendorOverlay,
213 listVendors,
214 resolveMapping,
215 applyMapping,
216 VENDORS_DIR,
217 };