package feeds import ( "encoding/json" "strings" ) // FieldMapping maps a feed source column/xpath onto a canonical product field. type FieldMapping struct { Source string `json:"source,omitempty"` Column string `json:"column,omitempty"` XPath string `json:"xpath,omitempty"` Target string `json:"target,omitempty"` FieldName string `json:"fieldName,omitempty"` Field string `json:"field,omitempty"` } func (m FieldMapping) sourceKey() string { for _, v := range []string{m.Source, m.Column, m.XPath} { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" } func (m FieldMapping) targetKey() string { for _, v := range []string{m.Target, m.FieldName, m.Field} { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" } // parseMappings accepts legacy object maps or array forms. func parseMappings(raw any) []FieldMapping { if raw == nil { return nil } switch t := raw.(type) { case []FieldMapping: return t case []any: out := make([]FieldMapping, 0, len(t)) for _, item := range t { if m, ok := fieldMappingFromUIEntry(item); ok { out = append(out, m) continue } b, err := json.Marshal(item) if err != nil { continue } var m FieldMapping if json.Unmarshal(b, &m) != nil { continue } if m.sourceKey() != "" && m.targetKey() != "" { out = append(out, m) } } return out case map[string]any: // Prefer wrapped { item_path, fields|mappings: [...] } from the mapping UI. if fields, ok := t["fields"]; ok { return parseMappings(fields) } if nested, ok := t["mappings"]; ok { return parseMappings(nested) } out := make([]FieldMapping, 0, len(t)) for source, val := range t { if source == "item_path" { continue } switch v := val.(type) { case string: out = append(out, FieldMapping{Source: source, Target: v}) case map[string]any: b, _ := json.Marshal(v) var m FieldMapping _ = json.Unmarshal(b, &m) if m.sourceKey() == "" { m.Source = source } if m.targetKey() == "" { continue } out = append(out, m) default: b, err := json.Marshal(v) if err != nil { continue } var m FieldMapping if json.Unmarshal(b, &m) != nil { continue } if m.sourceKey() == "" { m.Source = source } if m.targetKey() != "" { out = append(out, m) } } } return out default: b, err := json.Marshal(raw) if err != nil { return nil } var arr []FieldMapping if json.Unmarshal(b, &arr) == nil && len(arr) > 0 { return arr } var obj map[string]any if json.Unmarshal(b, &obj) == nil { return parseMappings(obj) } return nil } } // fieldMappingFromUIEntry unwraps dashboard rows shaped like // {"key":"Export/Item/ID","mapping":{"fieldName":"id","xpath":"Export/Item/ID"}}. func fieldMappingFromUIEntry(item any) (FieldMapping, bool) { m, ok := item.(map[string]any) if !ok { return FieldMapping{}, false } nested, ok := m["mapping"] if !ok { return FieldMapping{}, false } nestedMap, ok := nested.(map[string]any) if !ok { b, err := json.Marshal(nested) if err != nil { return FieldMapping{}, false } nestedMap = map[string]any{} if json.Unmarshal(b, &nestedMap) != nil { return FieldMapping{}, false } } b, err := json.Marshal(nestedMap) if err != nil { return FieldMapping{}, false } var fm FieldMapping if json.Unmarshal(b, &fm) != nil { return FieldMapping{}, false } if key, _ := m["key"].(string); strings.TrimSpace(key) != "" { if fm.XPath == "" { fm.XPath = strings.TrimSpace(key) } if fm.Source == "" && fm.Column == "" { fm.Source = strings.TrimSpace(key) } } if fm.sourceKey() == "" || fm.targetKey() == "" { return FieldMapping{}, false } return fm, true } // applyMappings copies source values into mapped_data as-is (including "0"). // Derivation and zero-dimension cleanup happen later in processing.EnrichMapped. func applyMappings(row map[string]string, mappings []FieldMapping) (mapped map[string]any, gtin string) { mapped = make(map[string]any, len(mappings)) for _, m := range mappings { src := m.sourceKey() tgt := m.targetKey() if src == "" || tgt == "" || strings.EqualFold(tgt, "none") { continue } if isSpecificationsTarget(tgt) { if obj := resolveSpecifications(row, src); obj != nil { if raw, ok := obj["_raw"]; ok && len(obj) == 1 { mapped["specifications"] = raw } else { delete(obj, "_raw") mapped["specifications"] = obj } if !strings.EqualFold(tgt, "specifications") { mapped[tgt] = mapped["specifications"] } } continue } val, ok := lookupRow(row, src) if !ok || strings.TrimSpace(val) == "" { continue } val = strings.TrimSpace(val) mapped[tgt] = val if strings.EqualFold(tgt, "gtin") || strings.EqualFold(tgt, "ean") || strings.EqualFold(tgt, "upc") { gtin = val mapped["gtin"] = val } } if gtin == "" { for _, k := range []string{"gtin", "ean", "upc", "EAN", "GTIN", "barcode"} { if v, ok := lookupRow(row, k); ok && strings.TrimSpace(v) != "" { gtin = strings.TrimSpace(v) mapped["gtin"] = gtin break } } } return mapped, gtin } func isSpecificationsTarget(tgt string) bool { switch strings.ToLower(strings.TrimSpace(tgt)) { case "specifications", "specs", "specification": return true default: return false } } // resolveSpecifications builds a label→value map from nested XML children, // CDATA HTML, or flat strings. Empty/missing yields nil. func resolveSpecifications(row map[string]string, src string) map[string]string { src = strings.Trim(strings.TrimSpace(src), "/") if children := collectPrefixed(row, src); len(children) > 0 { return normalizeSpecKeys(children) } if val, ok := lookupRow(row, src); ok { if pairs := ParseSpecifications(val); len(pairs) > 0 { return specsToMap(pairs) } // Keep non-empty raw blob so UI/backfill can still parse later. if strings.TrimSpace(val) != "" && !isEmptySpecBlob(val) { return map[string]string{"_raw": strings.TrimSpace(val)} } } return nil } func normalizeSpecKeys(in map[string]string) map[string]string { if len(in) == 0 { return nil } out := make(map[string]string, len(in)) for k, v := range in { key := CanonicalAttributeKey(k) if key == "" || strings.TrimSpace(v) == "" { continue } out[key] = v } if len(out) == 0 { return nil } return out } func lookupRow(row map[string]string, key string) (string, bool) { key = strings.Trim(strings.TrimSpace(key), "/") if key == "" { return "", false } if v, ok := row[key]; ok { return v, true } // Case-insensitive exact path match (prefer longest key). var bestKey string for k := range row { if strings.EqualFold(k, key) { if len(k) >= len(bestKey) { bestKey = k } } } if bestKey != "" { return row[bestKey], true } if !strings.Contains(key, "/") { return "", false } // Nested path: prefer a unique row key that ends with the same path suffix. leaf := leafName(key) suffix := "/" + strings.ToLower(key) var suffixHits []string var leafHits []string for k := range row { lk := strings.ToLower(k) if strings.HasSuffix(lk, suffix) || lk == strings.ToLower(key) { suffixHits = append(suffixHits, k) continue } if strings.EqualFold(leafName(k), leaf) { leafHits = append(leafHits, k) } } if len(suffixHits) == 1 { return row[suffixHits[0]], true } if len(suffixHits) > 1 { // Prefer the shortest (most specific relative) match. best := suffixHits[0] for _, h := range suffixHits[1:] { if len(h) < len(best) { best = h } } return row[best], true } // Fall back to bare leaf only when unambiguous. if len(leafHits) == 1 { return row[leafHits[0]], true } if v, ok := row[leaf]; ok && len(leafHits) <= 1 { return v, true } return "", false }