Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+323
View File
@@ -0,0 +1,323 @@
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
}