stash/pkg/models/search.go

168 lines
4.0 KiB
Go

package models
import "strings"
const (
or = "OR"
orSymbol = "|"
notPrefix = '-'
phraseChar = '"'
)
// SearchSpecs provides the specifications for text-based searches.
type SearchSpecs struct {
// MustHave specifies all of the terms that must appear in the results.
MustHave []string
// AnySets specifies sets of terms where one of each set must appear in the results.
AnySets [][]string
// MustNot specifies all terms that must not appear in the results.
MustNot []string
}
// combinePhrases detects quote characters at the start and end of
// words and combines the contents into a single word.
func combinePhrases(words []string) []string {
var ret []string
startIndex := -1
for i, w := range words {
if startIndex == -1 {
// looking for start of phrase
// this could either be " or -"
ww := w
if len(w) > 0 && w[0] == notPrefix {
ww = w[1:]
}
if len(ww) > 0 && ww[0] == phraseChar && (len(ww) < 2 || ww[len(ww)-1] != phraseChar) {
startIndex = i
continue
}
ret = append(ret, w)
} else if len(w) > 0 && w[len(w)-1] == phraseChar { // looking for end of phrase
// combine words
phrase := strings.Join(words[startIndex:i+1], " ")
// add to return value
ret = append(ret, phrase)
startIndex = -1
}
}
if startIndex != -1 {
ret = append(ret, words[startIndex:]...)
}
return ret
}
func extractOrConditions(words []string, searchSpec *SearchSpecs) []string {
for foundOr := true; foundOr; {
foundOr = false
for i, w := range words {
if i > 0 && i < len(words)-1 && (strings.EqualFold(w, or) || w == orSymbol) {
// found an OR keyword
// first operand will be the last word
startIndex := i - 1
// find the last operand
// this will be the last word not preceded by OR
lastIndex := len(words) - 1
for ii := i + 2; ii < len(words); ii += 2 {
if !strings.EqualFold(words[ii], or) {
lastIndex = ii - 1
break
}
}
foundOr = true
// combine the words into an any set
var set []string
for ii := startIndex; ii <= lastIndex; ii += 2 {
word := extractPhrase(words[ii])
if word == "" {
continue
}
set = append(set, word)
}
searchSpec.AnySets = append(searchSpec.AnySets, set)
// take out the OR'd words
words = append(words[0:startIndex], words[lastIndex+1:]...)
// break and reparse
break
}
}
}
return words
}
func extractNotConditions(words []string, searchSpec *SearchSpecs) []string {
var ret []string
for _, w := range words {
if len(w) > 1 && w[0] == notPrefix {
word := extractPhrase(w[1:])
if word == "" {
continue
}
searchSpec.MustNot = append(searchSpec.MustNot, word)
} else {
ret = append(ret, w)
}
}
return ret
}
func extractPhrase(w string) string {
if len(w) > 1 && w[0] == phraseChar && w[len(w)-1] == phraseChar {
return w[1 : len(w)-1]
}
return w
}
// ParseSearchString parses the Q value and returns a SearchSpecs object.
//
// By default, any words in the search value must appear in the results.
// Words encompassed by quotes (") as treated as a single term.
// Where keyword "OR" (case-insensitive) appears (and is not part of a quoted phrase), one of the
// OR'd terms must appear in the results.
// Where a keyword is prefixed with "-", that keyword must not appear in the results.
// Where OR appears as the first or last term, or where one of the OR operands has a
// not prefix, then the OR is treated literally.
func ParseSearchString(s string) SearchSpecs {
s = strings.TrimSpace(s)
if s == "" {
return SearchSpecs{}
}
// break into words
words := strings.Split(s, " ")
// combine phrases first, then extract OR conditions, then extract NOT conditions
// and the leftovers will be AND'd
ret := SearchSpecs{}
words = combinePhrases(words)
words = extractOrConditions(words, &ret)
words = extractNotConditions(words, &ret)
for _, w := range words {
// ignore empty quotes
word := extractPhrase(w)
if word == "" {
continue
}
ret.MustHave = append(ret.MustHave, word)
}
return ret
}