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.
This commit is contained in:
2026-02-22 21:12:19 -05:00
parent 5efe7581ed
commit 12eb55f4b8
2 changed files with 258 additions and 0 deletions

104
internal/filter/filter.go Normal file
View File

@@ -0,0 +1,104 @@
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
}