package processing import ( "fmt" "strings" "unicode" ) // knownAttrKeyAliases maps compact feed/spec tokens onto compact category // attribute_key tokens (Slovenian catalog keys + common English synonyms). var knownAttrKeyAliases = map[string]string{ "velikostzaslona": "diagonalazaslona", "screensize": "diagonalazaslona", "screen": "diagonalazaslona", "diagonal": "diagonalazaslona", "diagonala": "diagonalazaslona", "nastavljivpovisini": "nastavljivopovisini", "heightadjustable": "nastavljivopovisini", "zaslonnadotik": "zaslonobcutljivnadotik", "touchscreen": "zaslonobcutljivnadotik", "tipmatrike": "vrstapanela", "tippanela": "vrstapanela", "paneltype": "vrstapanela", "panel": "vrstapanela", "zvocniki": "vgrajenizvocniki", "speakers": "vgrajenizvocniki", "ukrivljenzaslon": "oblikazaslona", "curved": "oblikazaslona", "slusalkezmikrofonom": "vgrajenmikrofon", "mikrofon": "vgrajenmikrofon", "builtinmic": "vgrajenmikrofon", "microphone": "vgrajenmikrofon", "brezzicne": "brezzicnapovezava", "brezzicna": "brezzicnapovezava", "wireless": "brezzicnapovezava", "bluetooth": "brezzicnatehnologija", "bt": "brezzicnatehnologija", "anc": "odpravljanjehrupa", "noisecancelling": "odpravljanjehrupa", "noisecanceling": "odpravljanjehrupa", "odpravljanjehrupa": "odpravljanjehrupa", "resolution": "locljivost", "responsetime": "odzivnicas", } // MapAttrsOntoAllowedKeys remaps feed/spec keys onto category_attributes keys // when a confident match exists (exact compact, known alias, or unique token // subset). Existing allowed/core values are never overwritten. When allowed is // nil or empty, attrs are returned unchanged (caller still filters). func MapAttrsOntoAllowedKeys(attrs map[string]any, allowed map[string]struct{}) map[string]any { if len(attrs) == 0 || len(allowed) == 0 { return attrs } prefer := preferredAllowedKeys(allowed) if len(prefer) == 0 { return attrs } out := make(map[string]any, len(attrs)+len(prefer)) for k, v := range attrs { out[k] = v } for k, v := range attrs { if !attrValuePresent(v) { continue } canon := canonicalizeAttrKey(k) if canon != "" { if _, ok := coreCharacteristicAttrKeys[canon]; ok { continue } if _, ok := allowed[canon]; ok { continue } if _, ok := allowed[strings.ToLower(strings.TrimSpace(k))]; ok { continue } } target := resolveAllowedAttrKey(k, prefer) if target == "" || target == canon || target == k { continue } if attrValuePresent(out[target]) { continue } out[target] = v } return out } func preferredAllowedKeys(allowed map[string]struct{}) map[string]string { // compact → preferred output key (canonical snake_case when possible) prefer := make(map[string]string, len(allowed)) for k := range allowed { c := compactAttrToken(k) if c == "" { continue } canon := canonicalizeAttrKey(k) if canon == "" { canon = strings.ToLower(strings.TrimSpace(k)) } if prev, ok := prefer[c]; ok { // Prefer underscore form over hyphen duplicates from loadCategoryAttributeKeySets. if strings.Contains(canon, "_") && !strings.Contains(prev, "_") { prefer[c] = canon } continue } prefer[c] = canon } return prefer } func resolveAllowedAttrKey(feedKey string, prefer map[string]string) string { ck := compactAttrToken(feedKey) if ck == "" { return "" } if target, ok := prefer[ck]; ok { return target } if alias, ok := knownAttrKeyAliases[ck]; ok { if target, ok := prefer[alias]; ok { return target } } return softMatchAllowedKey(feedKey, ck, prefer) } func softMatchAllowedKey(feedKey, feedCompact string, prefer map[string]string) string { feedTokens := attrKeyTokens(feedKey) if len(feedTokens) == 0 && feedCompact == "" { return "" } var hits []string for compact, target := range prefer { if feedCompact != "" && (strings.Contains(compact, feedCompact) || strings.Contains(feedCompact, compact)) { // Require meaningful overlap (avoid tiny tokens matching everything). shorter, longer := feedCompact, compact if len(shorter) > len(longer) { shorter, longer = longer, shorter } if len(shorter) >= 6 && len(shorter)*10 >= len(longer)*6 { hits = append(hits, target) continue } } if len(feedTokens) == 0 { continue } allowedTokens := attrKeyTokens(target) if len(allowedTokens) == 0 { allowedTokens = attrKeyTokens(compact) } if tokensSubsetMatch(feedTokens, allowedTokens) { hits = append(hits, target) } } if len(hits) != 1 { return "" } return hits[0] } func tokensSubsetMatch(feed, allowed []string) bool { significant := 0 for _, ft := range feed { if len(ft) < 3 { continue } significant++ ok := false for _, at := range allowed { if at == ft || strings.HasPrefix(at, ft) || strings.HasPrefix(ft, at) { ok = true break } } if !ok { return false } } return significant >= 2 } func attrKeyTokens(k string) []string { k = strings.ToLower(strings.TrimSpace(k)) if k == "" { return nil } var b strings.Builder var out []string flush := func() { if b.Len() == 0 { return } tok := b.String() b.Reset() tok = compactAttrToken(tok) if len(tok) >= 2 { out = append(out, tok) } } for _, r := range k { r = foldAttrRune(r) switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) default: flush() } } flush() return out } func compactAttrToken(k string) string { var b strings.Builder b.Grow(len(k)) for _, r := range strings.ToLower(k) { r = foldAttrRune(r) if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { b.WriteRune(r) } } return b.String() } func foldAttrRune(r rune) rune { switch r { case 'š', 'ś', 'ş': return 's' case 'č', 'ć', 'ç': return 'c' case 'ž', 'ź', 'ż': return 'z' case 'đ': return 'd' case 'ä', 'á', 'à', 'â', 'ã', 'å': return 'a' case 'ë', 'é', 'è', 'ê': return 'e' case 'ï', 'í', 'ì', 'î': return 'i' case 'ö', 'ó', 'ò', 'ô', 'õ': return 'o' case 'ü', 'ú', 'ù', 'û': return 'u' case 'ý', 'ÿ': return 'y' } if unicode.IsLetter(r) { return unicode.ToLower(r) } return r } func attrValuePresent(v any) bool { if v == nil { return false } s := strings.TrimSpace(fmt.Sprint(v)) return s != "" && s != "" }