package processing import ( "strconv" "strings" "unicode" ) // categoryDisplayLabel returns the human category name for meta / synthesize / // {{category}}. Prefers CategoryName; never falls back to a digits-only unique_id. func categoryDisplayLabel(result StepResult) string { if n := strings.TrimSpace(result.CategoryName); n != "" { return n } cat := strings.TrimSpace(result.Category) if cat == "" || isDigitsOnlyCategoryID(cat) { return "" } return cat } // resolveCategoryDisplayName looks up unique_id → name; returns "" when unknown // so callers do not treat "50" as a human label. func resolveCategoryDisplayName(uid string, namesByUID map[string]string) string { uid = strings.TrimSpace(uid) if uid == "" || len(namesByUID) == 0 { return "" } return strings.TrimSpace(namesByUID[uid]) } func syncCategoryName(out *StepResult, namesByUID map[string]string) { if out == nil { return } if n := resolveCategoryDisplayName(out.Category, namesByUID); n != "" { out.CategoryName = n } } func isDigitsOnlyCategoryID(s string) bool { if s == "" { return false } for _, r := range s { if !unicode.IsDigit(r) { return false } } return true } // categoryUniqueIDFromAny extracts a company category unique_id from feed/mapped // shapes: plain string/number, nested {unique_id|category_unique_id|#text|…}, or // a one-element array of those. Returns "" for empty/"none"/unusable values. func categoryUniqueIDFromAny(v any) string { if v == nil { return "" } switch t := v.(type) { case string: return normalizeCategoryUniqueID(t) case float64: if t == float64(int64(t)) { return normalizeCategoryUniqueID(strconv.FormatInt(int64(t), 10)) } return normalizeCategoryUniqueID(strconv.FormatFloat(t, 'f', -1, 64)) case float32: return categoryUniqueIDFromAny(float64(t)) case int: return normalizeCategoryUniqueID(strconv.Itoa(t)) case int64: return normalizeCategoryUniqueID(strconv.FormatInt(t, 10)) case int32: return normalizeCategoryUniqueID(strconv.FormatInt(int64(t), 10)) case jsonNumberStringer: return normalizeCategoryUniqueID(t.String()) case map[string]any: for _, k := range []string{ "unique_id", "category_unique_id", "categoryUniqueId", "#text", "text", "id", "value", "code", // Last: display name only (resolved to unique_id later via taxonomy). "name", } { if s := categoryUniqueIDFromAny(t[k]); s != "" { return s } } return "" case []any: for _, item := range t { if s := categoryUniqueIDFromAny(item); s != "" { return s } } return "" default: return "" } } // jsonNumberStringer matches encoding/json.Number without importing encoding/json here. type jsonNumberStringer interface { String() string } func normalizeCategoryUniqueID(s string) string { s = strings.TrimSpace(s) if s == "" || s == "" || strings.EqualFold(s, "none") { return "" } return s } // categoryUniqueIDFromMaps returns the first usable category unique_id from maps // (normalized/mapped/raw). Prefer canonical keys; mapped should win by call order. func categoryUniqueIDFromMaps(maps ...map[string]any) string { for _, m := range maps { if m == nil { continue } for _, k := range []string{ "category", "category_unique_id", "categoryUniqueId", "Category", "product_type", "productType", } { if s := categoryUniqueIDFromAny(m[k]); s != "" { return s } } } return "" } // coerceMappedCategoryUniqueID writes a string unique_id onto m["category"] when // category (or category_unique_id) can be resolved — so RunSteps string paths stay deterministic. func coerceMappedCategoryUniqueID(m map[string]any) string { if m == nil { return "" } cat := categoryUniqueIDFromMaps(m) if cat == "" { return "" } m["category"] = cat if _, ok := m["category_unique_id"]; !ok { m["category_unique_id"] = cat } return cat } // applyCategoryFromMapped sets StepResult.Category from feed/mapped unique_id when present. // Does not clear an existing Category when maps lack a usable value (caller may preserve prior). func applyCategoryFromMapped(out *StepResult, maps ...map[string]any) { if out == nil { return } cat := categoryUniqueIDFromMaps(maps...) if cat == "" { return } out.Category = SanitizeText(cat) if out.FieldSources == nil { out.FieldSources = map[string]any{} } out.FieldSources["category"] = "mapped" } // resolveCompanyCategoryUniqueID maps a feed/vector/prior category token onto a // company taxonomy unique_id. Accepts an already-valid unique_id, or a display // name (case-insensitive) from namesByUID. When valid is non-empty, the result // must be in that set. Returns "" when unresolvable. func resolveCompanyCategoryUniqueID(raw string, namesByUID map[string]string, valid map[string]struct{}) string { raw = strings.TrimSpace(raw) if raw == "" || strings.EqualFold(raw, "none") { return "" } if len(valid) > 0 { if _, ok := valid[raw]; ok { return raw } } else if _, ok := namesByUID[raw]; ok { return raw } if len(namesByUID) == 0 { return "" } for uid, name := range namesByUID { uid = strings.TrimSpace(uid) if uid == "" { continue } if !strings.EqualFold(strings.TrimSpace(name), raw) { continue } if len(valid) == 0 { return uid } if _, ok := valid[uid]; ok { return uid } } return "" } // coerceCategoryToCompanyUniqueID rewrites out.Category from a display name (or // other alias) onto the taxonomy unique_id before filterCategoryIfInvalid. // Without this, feed/vector category names are wiped even when they match categories.name. func coerceCategoryToCompanyUniqueID(out *StepResult, namesByUID map[string]string, valid map[string]struct{}) { if out == nil { return } cat := strings.TrimSpace(out.Category) if cat == "" { return } resolved := resolveCompanyCategoryUniqueID(cat, namesByUID, valid) if resolved == "" || resolved == cat { return } out.Category = SanitizeText(resolved) if out.FieldSources == nil { out.FieldSources = map[string]any{} } if _, ok := out.FieldSources["category"]; !ok { out.FieldSources["category"] = "name_coerced" } } // filterCategoryIfInvalid clears Category when the company has a known unique_id set // and the value is not in that set. Empty valid set means "skip validation" (tests / no taxonomy). // Call coerceCategoryToCompanyUniqueID first so display names are not dropped. func filterCategoryIfInvalid(out *StepResult, valid map[string]struct{}) { if out == nil || len(valid) == 0 { return } cat := strings.TrimSpace(out.Category) if cat == "" { return } if _, ok := valid[cat]; ok { return } out.Category = "" if out.FieldSources != nil { delete(out.FieldSources, "category") } out.Notes = append(out.Notes, "category: ignored (unknown unique_id for company)") }