GET /api/opportunities/search?q= returns the UUIDs of the matching opportunities, and the frontend filters its list by them. Words without a prompt search the title and description; title:, buyer:, buyer_id: and cpv: search only their own field, and a leading ! leaves out what a term matches. Repeats of one prompt are OR, everything else AND. CPV terms are checked per code: cpv:72 !cpv:7221 needs a code starting 72 that doesn't start 7221. Unknown prompts are reported, and every response carries the help that the search box's "?" panel shows. Migration 00011 adds the description and buyer name to the full-text index, and indexes CPV descriptions.
223 lines
6.3 KiB
Go
223 lines
6.3 KiB
Go
// Package search parses the general opportunity search: words and quoted
|
|
// phrases, each optionally with a prompt naming the field it searches
|
|
// (title:, buyer:, buyer_id:, cpv:) and a leading "!" to negate it. The
|
|
// store runs a parsed Query (store.SearchOpportunities).
|
|
package search
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// Field is what a term searches.
|
|
type Field int
|
|
|
|
// Values of Field.
|
|
const (
|
|
// FieldText is a term with no prompt: the title or the description.
|
|
FieldText Field = iota
|
|
// FieldTitle is title:, the title only.
|
|
FieldTitle
|
|
// FieldBuyer is buyer:, the buyer's name.
|
|
FieldBuyer
|
|
// FieldBuyerID is buyer_id:, the buyer's ID, matched whole.
|
|
FieldBuyerID
|
|
// FieldCPV is cpv:, a CPV code prefix or words of a CPV description.
|
|
FieldCPV
|
|
)
|
|
|
|
// Prompt is a known prompt, with a line of help for the user.
|
|
type Prompt struct {
|
|
Name string `json:"name"`
|
|
Help string `json:"help"`
|
|
}
|
|
|
|
// Example is an example of the search syntax, with a line of help for the
|
|
// user.
|
|
type Example struct {
|
|
Example string `json:"example"`
|
|
Help string `json:"help"`
|
|
}
|
|
|
|
// Prompts are the known prompts, in the order to show them.
|
|
var Prompts = []Prompt{
|
|
{Name: "title", Help: "Words in the title only."},
|
|
{Name: "buyer", Help: "Words in the buyer's name."},
|
|
{
|
|
Name: "buyer_id",
|
|
Help: "The buyer's ID, whole and in any case (e.g. GB-FTS-148803).",
|
|
},
|
|
{
|
|
Name: "cpv",
|
|
Help: "The start of a CPV code (cpv:72), or words in a CPV code's description (cpv:food).",
|
|
},
|
|
}
|
|
|
|
// Syntax explains the search syntax, in the order to show it.
|
|
var Syntax = []Example{
|
|
{
|
|
Example: "cloud hosting",
|
|
Help: "Words without a prompt are searched for in the title and the description. Every word must be found, though not necessarily in the same one.",
|
|
},
|
|
{
|
|
Example: "migr",
|
|
Help: "A word also matches the start of a longer word, and other forms of it: migr finds migration, and migrating finds migration.",
|
|
},
|
|
{
|
|
Example: `"road repairs"`,
|
|
Help: "Quotes search for a phrase: its words together, in that order.",
|
|
},
|
|
{
|
|
Example: `title:"road repairs"`,
|
|
Help: "A prompt searches only its own field, for one word or one quoted phrase.",
|
|
},
|
|
{
|
|
Example: "buyer:leeds buyer:bradford",
|
|
Help: "Repeats of the same prompt match if any of them does: Leeds or Bradford.",
|
|
},
|
|
{
|
|
Example: "cloud buyer:leeds cpv:72",
|
|
Help: "Different prompts, and words without one, must all match.",
|
|
},
|
|
{
|
|
Example: "!cloud !buyer:leeds",
|
|
Help: "! in front of a word, a phrase or a prompt leaves out what it matches.",
|
|
},
|
|
{
|
|
Example: "cpv:72 !cpv:7221",
|
|
Help: "CPV terms are checked against each of an opportunity's codes: this needs a code starting 72 that doesn't start 7221. !cpv: without cpv: leaves out every opportunity with a matching code.",
|
|
},
|
|
}
|
|
|
|
// prompts maps each known prompt's name to its field.
|
|
var prompts = map[string]Field{
|
|
"title": FieldTitle,
|
|
"buyer": FieldBuyer,
|
|
"buyer_id": FieldBuyerID,
|
|
"cpv": FieldCPV,
|
|
}
|
|
|
|
// promptPattern matches a prompt at the start of a term: a name of letters
|
|
// and underscores, then a colon. A colon after anything else (12:00) is
|
|
// part of the term.
|
|
var promptPattern = regexp.MustCompile(`^([A-Za-z_]+):`)
|
|
|
|
// cpvCodePattern matches a CPV code or the start of one, with an optional
|
|
// check digit.
|
|
var cpvCodePattern = regexp.MustCompile(`^([0-9]+)(?:-[0-9])?$`)
|
|
|
|
// Term is one search term: a word or a quoted phrase, as typed.
|
|
type Term struct {
|
|
Field Field
|
|
Negated bool
|
|
Value string
|
|
}
|
|
|
|
// Words returns the term's words, split as the store's full-text index
|
|
// splits them: on anything but letters and numbers.
|
|
func (t Term) Words() []string {
|
|
return strings.FieldsFunc(t.Value, func(char rune) bool {
|
|
return !unicode.IsLetter(char) && !unicode.IsNumber(char)
|
|
})
|
|
}
|
|
|
|
// CPVCode returns the digits of a cpv: term that is a code or the start of
|
|
// one, without its check digit, and reports whether it is one. Any other
|
|
// cpv: term searches CPV descriptions.
|
|
func (t Term) CPVCode() (string, bool) {
|
|
match := cpvCodePattern.FindStringSubmatch(t.Value)
|
|
if match == nil {
|
|
return "", false
|
|
}
|
|
return match[1], true
|
|
}
|
|
|
|
// usable reports whether the term can be searched for: a buyer ID that
|
|
// isn't blank, or any other term with at least one word.
|
|
func (t Term) usable() bool {
|
|
if t.Field == FieldBuyerID {
|
|
return strings.TrimSpace(t.Value) != ""
|
|
}
|
|
return len(t.Words()) > 0
|
|
}
|
|
|
|
// Query is a parsed search.
|
|
type Query struct {
|
|
// Terms are the terms to search for, all usable.
|
|
Terms []Term
|
|
// UnknownPrompts are the prompts that aren't known, as typed, without
|
|
// their colon, once each. Their terms are left out of Terms.
|
|
UnknownPrompts []string
|
|
}
|
|
|
|
// MatchesAll reports whether the query has no terms, so it matches every
|
|
// opportunity.
|
|
func (q Query) MatchesAll() bool {
|
|
return len(q.Terms) == 0
|
|
}
|
|
|
|
// Parse parses text as a search. It never fails: text that isn't valid
|
|
// syntax, such as an unclosed quote, is read as plain words, and a term
|
|
// with no words (title: or !!!) is left out.
|
|
func Parse(text string) Query {
|
|
query := Query{
|
|
UnknownPrompts: []string{},
|
|
}
|
|
unknown := map[string]bool{}
|
|
|
|
for rest := strings.TrimSpace(text); rest != ""; {
|
|
var term Term
|
|
var prompt string
|
|
var known bool
|
|
term, prompt, known, rest = nextTerm(rest)
|
|
rest = strings.TrimLeftFunc(rest, unicode.IsSpace)
|
|
|
|
switch {
|
|
case !known:
|
|
if !unknown[prompt] {
|
|
unknown[prompt] = true
|
|
query.UnknownPrompts = append(query.UnknownPrompts, prompt)
|
|
}
|
|
case term.usable():
|
|
query.Terms = append(query.Terms, term)
|
|
}
|
|
}
|
|
|
|
return query
|
|
}
|
|
|
|
// nextTerm reads the term at the start of text, which mustn't start with
|
|
// space. It returns the term, its prompt as typed ("" if none), whether the
|
|
// prompt is known, and the text after the term.
|
|
func nextTerm(
|
|
text string,
|
|
) (term Term, prompt string, known bool, rest string) {
|
|
if strings.HasPrefix(text, "!") {
|
|
term.Negated = true
|
|
text = text[1:]
|
|
}
|
|
|
|
if match := promptPattern.FindStringSubmatch(text); match != nil {
|
|
prompt = match[1]
|
|
text = text[len(match[0]):]
|
|
}
|
|
term.Field, known = prompts[strings.ToLower(prompt)]
|
|
if prompt == "" {
|
|
term.Field, known = FieldText, true
|
|
}
|
|
|
|
if strings.HasPrefix(text, `"`) {
|
|
term.Value, rest, _ = strings.Cut(text[1:], `"`)
|
|
return term, prompt, known, rest
|
|
}
|
|
|
|
end := strings.IndexFunc(text, unicode.IsSpace)
|
|
if end < 0 {
|
|
end = len(text)
|
|
}
|
|
term.Value = text[:end]
|
|
|
|
return term, prompt, known, text[end:]
|
|
}
|