168 lines
5.6 KiB
Go
168 lines
5.6 KiB
Go
package utils
|
||
|
||
import (
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ParseLatLng reads the coordinate strings this schema stores — customers,
|
||
// customerlocations and tenantlocations all hold latitude/longitude as text
|
||
// — and says whether they name a real place.
|
||
//
|
||
// "0,0" is rejected along with blanks: nothing on this platform is in the
|
||
// Gulf of Guinea, and it is what an empty map picker saves.
|
||
func ParseLatLng(lat, lng string) (float64, float64, bool) {
|
||
la, err1 := strconv.ParseFloat(strings.TrimSpace(lat), 64)
|
||
lo, err2 := strconv.ParseFloat(strings.TrimSpace(lng), 64)
|
||
if err1 != nil || err2 != nil {
|
||
return 0, 0, false
|
||
}
|
||
if la < -90 || la > 90 || lo < -180 || lo > 180 || (la == 0 && lo == 0) {
|
||
return 0, 0, false
|
||
}
|
||
return la, lo, true
|
||
}
|
||
|
||
// HaversineKm is the great-circle distance between two points.
|
||
func HaversineKm(lat1, lng1, lat2, lng2 float64) float64 {
|
||
const earthRadiusKm = 6371.0
|
||
toRad := func(d float64) float64 { return d * math.Pi / 180 }
|
||
|
||
dLat := toRad(lat2 - lat1)
|
||
dLng := toRad(lng2 - lng1)
|
||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
|
||
return 2 * earthRadiusKm * math.Asin(math.Sqrt(a))
|
||
}
|
||
|
||
// OpenNow reads tenantlocations.opentime/closetime ("09:00", "21:30",
|
||
// "9:00 AM") against the wall clock. Unparsable or blank hours are treated as
|
||
// open: a shop that never filled the field in should not vanish from the
|
||
// list, and the ordering flow re-checks at checkout anyway.
|
||
func OpenNow(open, closeAt string, now time.Time) bool {
|
||
o, ok1 := parseClock(open)
|
||
c, ok2 := parseClock(closeAt)
|
||
if !ok1 || !ok2 || o == c {
|
||
return true
|
||
}
|
||
cur := now.Hour()*60 + now.Minute()
|
||
if o < c {
|
||
return cur >= o && cur < c
|
||
}
|
||
// Past midnight: "20:00" – "02:00".
|
||
return cur >= o || cur < c
|
||
}
|
||
|
||
func parseClock(s string) (int, bool) {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return 0, false
|
||
}
|
||
for _, layout := range []string{"15:04", "15:04:05", "3:04 PM", "3:04PM", "03:04 PM", "15.04"} {
|
||
if t, err := time.Parse(layout, strings.ToUpper(s)); err == nil {
|
||
return t.Hour()*60 + t.Minute(), true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// SearchTokens splits a label into the words worth matching on: lowercased,
|
||
// punctuation stripped, pack-size noise dropped. "Milk Bikis 100g" →
|
||
// ["milk", "bikis"]; the size is matched separately, if at all.
|
||
//
|
||
// A single character is kept when it follows a word, because in this
|
||
// catalogue that character is often the whole product: the "G" in "Parle G",
|
||
// the "K" in "Special K". Dropping it made "Parle G" score the same against
|
||
// "Parle-G Original Glucose Biscuits" as against "Parle Monaco Classic", and
|
||
// the tie went to Monaco. It is still dropped when it stands alone — a
|
||
// one-letter label is not a search — and bare multipliers ("2 x 50gm") are
|
||
// never words.
|
||
func SearchTokens(label string) []string {
|
||
var tokens []string
|
||
seen := make(map[string]bool)
|
||
kept := 0 // multi-character tokens so far: a lone letter needs one
|
||
var filler []string // packaging words, kept only if nothing else survives
|
||
for _, raw := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool {
|
||
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
|
||
}) {
|
||
if isPackSize(raw) || seen[raw] {
|
||
continue
|
||
}
|
||
if len(raw) < 2 && (kept == 0 || isMultiplier(raw)) {
|
||
continue
|
||
}
|
||
seen[raw] = true
|
||
if isPackaging(raw) {
|
||
filler = append(filler, raw)
|
||
continue
|
||
}
|
||
if len(raw) > 1 {
|
||
kept++
|
||
}
|
||
tokens = append(tokens, raw)
|
||
}
|
||
// "Dettol bottle pack" is a scan of Dettol. Only when the label is nothing
|
||
// but packaging does that packaging become the search.
|
||
if len(tokens) == 0 {
|
||
return filler
|
||
}
|
||
return tokens
|
||
}
|
||
|
||
// isPackaging is what the label says about the wrapper rather than the
|
||
// product: "Dettol bottle pack", "Parle G biscuit pack". Lens reads these off
|
||
// the packet and no catalogue name carries them, so every one of them used to
|
||
// be a word the row had to contain — and "Dettol bottle pack" found no Dettol
|
||
// at all. Treated like pack sizes: real words, just not the product's name.
|
||
func isPackaging(tok string) bool {
|
||
switch tok {
|
||
case "pack", "packs", "packet", "packets", "bottle", "bottles",
|
||
"box", "boxes", "jar", "jars", "tin", "tins", "pouch", "pouches",
|
||
"carton", "cartons", "sachet", "sachets", "combo", "refill":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isMultiplier is the "x" of "2 x 50gm" and the "n" of a multipack — a single
|
||
// character that joins sizes rather than naming a product.
|
||
func isMultiplier(tok string) bool {
|
||
return tok == "x" || tok == "n"
|
||
}
|
||
|
||
// FoldSeparators turns every run of punctuation into one space, so a label
|
||
// typed without the brand's own punctuation still matches it: "Parle G" and
|
||
// "Parle-G" both fold to "parle g". Lens reads letterforms off a packet, and
|
||
// people type what they see, so the hyphen is not reliably either present or
|
||
// absent on the way in.
|
||
func FoldSeparators(s string) string {
|
||
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
|
||
}), " ")
|
||
}
|
||
|
||
// TightenLabel removes separators outright rather than folding them, catching
|
||
// the other way people write a hyphenated name: "ParleG" against "Parle-G".
|
||
func TightenLabel(s string) string {
|
||
return strings.ReplaceAll(FoldSeparators(s), " ", "")
|
||
}
|
||
|
||
// isPackSize is "100g", "1kg", "500ml", "2l", "250gm" — a number with a unit
|
||
// glued on, or a bare number.
|
||
func isPackSize(tok string) bool {
|
||
digits := 0
|
||
for digits < len(tok) && tok[digits] >= '0' && tok[digits] <= '9' {
|
||
digits++
|
||
}
|
||
if digits == 0 {
|
||
return false
|
||
}
|
||
switch tok[digits:] {
|
||
case "", "g", "gm", "gms", "kg", "ml", "l", "ltr", "pcs", "pc", "x", "n":
|
||
return true
|
||
}
|
||
return false
|
||
}
|