Files
pubcli/internal/filter/filter.go
teernisse 12eb55f4b8 Add deal filtering engine with BOGO, category, department, and keyword support
Composable filter pipeline that processes SavingItem slices through
chained predicates: BOGO detection (category match), exact category
match, substring department match, and keyword search across title
and description fields. All text matching is case-insensitive.

Includes utility functions for HTML entity unescaping (CleanText),
nil-safe string pointer dereferencing (Deref), and case-insensitive
slice membership (ContainsIgnoreCase). An optional limit truncates
results after all filters are applied. Tests cover each filter in
isolation, combined filters, nil field safety, and the Categories
aggregation helper.
2026-02-22 21:41:46 -05:00

105 lines
2.4 KiB
Go

package filter
import (
"html"
"strings"
"github.com/tayloree/publix-deals/internal/api"
)
// Options holds all filter criteria.
type Options struct {
BOGO bool
Category string
Department string
Query string
Limit int
}
// Apply filters a slice of SavingItems according to the given options.
func Apply(items []api.SavingItem, opts Options) []api.SavingItem {
result := items
if opts.BOGO {
result = where(result, func(i api.SavingItem) bool {
return ContainsIgnoreCase(i.Categories, "bogo")
})
}
if opts.Category != "" {
result = where(result, func(i api.SavingItem) bool {
return ContainsIgnoreCase(i.Categories, opts.Category)
})
}
if opts.Department != "" {
dept := strings.ToLower(opts.Department)
result = where(result, func(i api.SavingItem) bool {
return strings.Contains(strings.ToLower(Deref(i.Department)), dept)
})
}
if opts.Query != "" {
q := strings.ToLower(opts.Query)
result = where(result, func(i api.SavingItem) bool {
title := strings.ToLower(CleanText(Deref(i.Title)))
desc := strings.ToLower(CleanText(Deref(i.Description)))
return strings.Contains(title, q) || strings.Contains(desc, q)
})
}
if opts.Limit > 0 && opts.Limit < len(result) {
result = result[:opts.Limit]
}
return result
}
// Categories returns a map of category name to count across all items.
func Categories(items []api.SavingItem) map[string]int {
cats := make(map[string]int)
for _, item := range items {
for _, c := range item.Categories {
cats[c]++
}
}
return cats
}
// Deref safely dereferences a string pointer, returning "" for nil.
func Deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// CleanText unescapes HTML entities and normalizes whitespace.
func CleanText(s string) string {
s = html.UnescapeString(s)
s = strings.ReplaceAll(s, "\r\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
return strings.TrimSpace(s)
}
func where(items []api.SavingItem, fn func(api.SavingItem) bool) []api.SavingItem {
var result []api.SavingItem
for _, item := range items {
if fn(item) {
result = append(result, item)
}
}
return result
}
// ContainsIgnoreCase reports whether any element in slice matches val case-insensitively.
func ContainsIgnoreCase(slice []string, val string) bool {
for _, s := range slice {
if strings.EqualFold(s, val) {
return true
}
}
return false
}