mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Add search string parsing (#1982)
* Add search string parsing * Add manual page
This commit is contained in:
167
pkg/models/search.go
Normal file
167
pkg/models/search.go
Normal file
@@ -0,0 +1,167 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user