updated vendor
This commit is contained in:
parent
577cf4d482
commit
5e9ce5a306
8 changed files with 1306 additions and 5 deletions
14
vendor/github.com/bcampbell/qs/LICENSE
generated
vendored
Normal file
14
vendor/github.com/bcampbell/qs/LICENSE
generated
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
Copyright 2015 Ben Campbell
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
77
vendor/github.com/bcampbell/qs/README.md
generated
vendored
Normal file
77
vendor/github.com/bcampbell/qs/README.md
generated
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
[](https://godoc.org/github.com/bcampbell/qs)
|
||||
|
||||
## Overview
|
||||
|
||||
qs implements a query language for the [Bleve](http://www.blevesearch.com/)
|
||||
text indexer library.
|
||||
|
||||
The query syntax is aiming to be about the same as those used by
|
||||
[Lucene](http://lucene.apache.org/core/5_3_1/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#package_description)
|
||||
and [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax).
|
||||
|
||||
The full syntax is described in [syntax.md](syntax.md).
|
||||
|
||||
## Usage
|
||||
|
||||
import "github.com/bcampbell/qs"
|
||||
|
||||
query, err := qs.Parse("grapefruit lemon orange lime")
|
||||
|
||||
Or, to create a parse which uses AND as the default operator:
|
||||
|
||||
p := qs.Parser{DefaultOp: qs.AND}
|
||||
query,err := p.Parse("grapefruit lemon orange lime")
|
||||
|
||||
|
||||
|
||||
## Quick Examples
|
||||
|
||||
Documents containing the term `grapefruit`:
|
||||
|
||||
grapefruit
|
||||
|
||||
There is support for boolean AND, OR and NOT:
|
||||
|
||||
grapefruit OR lemon
|
||||
grapefruit AND lemon
|
||||
grapefruit AND NOT lemon
|
||||
|
||||
Phrases are indicated with quotes:
|
||||
|
||||
"navel orange"
|
||||
|
||||
The default boolean operator is OR, so these two are equivalent when using the default parser:
|
||||
|
||||
grapefruit lemon
|
||||
grapefruit OR lemon
|
||||
|
||||
|
||||
Fields:
|
||||
|
||||
tags:citrus
|
||||
headline:"How to Make the Perfect Negroni"
|
||||
|
||||
Grouping:
|
||||
|
||||
(lemon AND lime) OR (orange AND grapefruit)
|
||||
|
||||
Boosting term relevance:
|
||||
|
||||
fruit grapefruit^2
|
||||
|
||||
Inclusive ranges in square brackets, Exclusive ranges in curly brackets:
|
||||
|
||||
date:[2012-01-01 TO 2012-12-31]
|
||||
count:{0 TO 100}
|
||||
|
||||
Wildcard markers `*` (any character sequence, 0 or more) and `?` (any single character)
|
||||
eg to match `foot`, `fort`, `fret` etc:
|
||||
|
||||
f??t
|
||||
|
||||
eg to match `ant`, `anteater`, `antidisestablishmentarianism` etc:
|
||||
|
||||
ant*
|
||||
|
||||
|
||||
|
||||
17
vendor/github.com/bcampbell/qs/doc.go
generated
vendored
Normal file
17
vendor/github.com/bcampbell/qs/doc.go
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
Package qs is a query language parser for Bleve (http://www.blevesearch.com).
|
||||
|
||||
Example:
|
||||
|
||||
import "github.com/bcampbell/qs"
|
||||
|
||||
query, err := qs.Parse("grapefruit lemon orange lime")
|
||||
|
||||
Or, to create a parse which uses AND as the default operator:
|
||||
|
||||
p := qs.Parser{DefaultOp: qs.AND}
|
||||
query,err := p.Parse("grapefruit lemon orange lime")
|
||||
|
||||
|
||||
*/
|
||||
package qs
|
||||
251
vendor/github.com/bcampbell/qs/lex.go
generated
vendored
Normal file
251
vendor/github.com/bcampbell/qs/lex.go
generated
vendored
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package qs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TODO: handle escaping special characters:
|
||||
|
||||
type tokType int
|
||||
|
||||
const (
|
||||
tEOF tokType = iota
|
||||
tERROR
|
||||
tLITERAL
|
||||
tQUOTED
|
||||
tPLUS
|
||||
tMINUS
|
||||
tCOLON
|
||||
tEQUAL
|
||||
tGREATER
|
||||
tLESS
|
||||
tOR
|
||||
tAND
|
||||
tNOT
|
||||
tLPAREN
|
||||
tRPAREN
|
||||
tLSQUARE
|
||||
tRSQUARE
|
||||
tLBRACE
|
||||
tRBRACE
|
||||
tTO
|
||||
tBOOST
|
||||
tFUZZY
|
||||
)
|
||||
|
||||
func (t tokType) String() string {
|
||||
|
||||
tokTypes := map[tokType]string{
|
||||
tEOF: "tEOF",
|
||||
tERROR: "tERROR",
|
||||
tLITERAL: "tLITERAL",
|
||||
tQUOTED: "tQUOTED",
|
||||
tOR: "tOR",
|
||||
tAND: "tAND",
|
||||
tNOT: "tNOT",
|
||||
tTO: "tTO",
|
||||
tPLUS: "tPLUS",
|
||||
tMINUS: "tMINUS",
|
||||
tCOLON: "tCOLON",
|
||||
tEQUAL: "tEQUAL",
|
||||
tGREATER: "tGREATER",
|
||||
tLESS: "tLESS",
|
||||
tLPAREN: "tLPAREN",
|
||||
tRPAREN: "tRPAREN",
|
||||
tLSQUARE: "tLSQUARE",
|
||||
tRSQUARE: "tRSQUARE",
|
||||
tLBRACE: "tLBRACE",
|
||||
tRBRACE: "tRBRACE",
|
||||
tBOOST: "tBOOST",
|
||||
tFUZZY: "tFUZZY",
|
||||
}
|
||||
return tokTypes[t]
|
||||
}
|
||||
|
||||
// some single-rune tokens
|
||||
var singles = map[rune]tokType{
|
||||
'(': tLPAREN,
|
||||
')': tRPAREN,
|
||||
'[': tLSQUARE,
|
||||
']': tRSQUARE,
|
||||
'{': tLBRACE,
|
||||
'}': tRBRACE,
|
||||
':': tCOLON,
|
||||
'+': tPLUS,
|
||||
'-': tMINUS,
|
||||
'=': tEQUAL,
|
||||
'>': tGREATER,
|
||||
'<': tLESS,
|
||||
}
|
||||
|
||||
type token struct {
|
||||
typ tokType
|
||||
val string
|
||||
pos int
|
||||
}
|
||||
|
||||
type stateFn func(*lexer) stateFn
|
||||
|
||||
type lexer struct {
|
||||
input string
|
||||
tokens []token
|
||||
pos int
|
||||
prevpos int
|
||||
start int
|
||||
}
|
||||
|
||||
// lex takes an input string and breaks it up into an array of tokens.
|
||||
// The last token will be an tEOF, unless an error occurs, in which case
|
||||
// it will be a tERROR.
|
||||
func lex(input string) []token {
|
||||
l := &lexer{
|
||||
input: input,
|
||||
tokens: []token{},
|
||||
}
|
||||
// run state machine - each state returns the next state, or nil when finished
|
||||
for state := lexDefault; state != nil; {
|
||||
state = state(l)
|
||||
}
|
||||
return l.tokens
|
||||
}
|
||||
|
||||
func (l *lexer) next() rune {
|
||||
l.prevpos = l.pos
|
||||
if l.eof() {
|
||||
return 0
|
||||
}
|
||||
r, w := utf8.DecodeRuneInString(l.input[l.pos:])
|
||||
l.pos += w
|
||||
return r
|
||||
}
|
||||
func (l *lexer) eof() bool {
|
||||
return l.pos >= len(l.input)
|
||||
}
|
||||
|
||||
func (l *lexer) ignore() {
|
||||
l.start = l.pos
|
||||
}
|
||||
func (l *lexer) backup() {
|
||||
l.pos = l.prevpos
|
||||
}
|
||||
func (l *lexer) peek() rune {
|
||||
r := l.next()
|
||||
l.backup()
|
||||
return r
|
||||
}
|
||||
|
||||
func (l *lexer) emit(t tokType) {
|
||||
l.tokens = append(l.tokens, token{t, l.input[l.start:l.pos], l.start})
|
||||
l.start = l.pos
|
||||
}
|
||||
|
||||
func (l *lexer) emitError(msg string) {
|
||||
l.tokens = append(l.tokens, token{tERROR, msg, l.start})
|
||||
l.start = l.pos
|
||||
}
|
||||
|
||||
func lexDefault(l *lexer) stateFn {
|
||||
// skip space
|
||||
for {
|
||||
if l.eof() {
|
||||
l.emit(tEOF)
|
||||
return nil
|
||||
}
|
||||
r := l.next()
|
||||
if !unicode.IsSpace(r) {
|
||||
l.backup()
|
||||
l.ignore()
|
||||
if r == '~' || r == '^' {
|
||||
return lexSuffix
|
||||
}
|
||||
if typ, got := singles[r]; got {
|
||||
l.next()
|
||||
l.emit(typ)
|
||||
return lexDefault
|
||||
}
|
||||
|
||||
if r == '"' || r == '\'' {
|
||||
return lexQuoted
|
||||
}
|
||||
|
||||
return lexText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lexText(l *lexer) stateFn {
|
||||
// non-space characters which terminate a literal
|
||||
//stopChars := `&|:(){}[]^~`
|
||||
stopChars := `:(){}[]^~`
|
||||
|
||||
for {
|
||||
if l.eof() {
|
||||
break
|
||||
}
|
||||
r := l.next()
|
||||
if unicode.IsSpace(r) || strings.ContainsRune(stopChars, r) {
|
||||
l.backup()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch l.input[l.start:l.pos] {
|
||||
case "OR":
|
||||
l.emit(tOR)
|
||||
case "AND":
|
||||
l.emit(tAND)
|
||||
case "NOT":
|
||||
l.emit(tNOT)
|
||||
case "TO":
|
||||
l.emit(tTO)
|
||||
default:
|
||||
l.emit(tLITERAL)
|
||||
}
|
||||
|
||||
return lexDefault
|
||||
}
|
||||
|
||||
func lexQuoted(l *lexer) stateFn {
|
||||
q := l.next()
|
||||
for {
|
||||
if l.eof() {
|
||||
l.emitError("unclosed quote")
|
||||
return nil
|
||||
}
|
||||
r := l.next()
|
||||
if r == q {
|
||||
break
|
||||
}
|
||||
}
|
||||
l.emit(tQUOTED)
|
||||
return lexDefault
|
||||
}
|
||||
|
||||
func lexSuffix(l *lexer) stateFn {
|
||||
kind := l.next() // '^' or '~'
|
||||
|
||||
// number (optional)
|
||||
for {
|
||||
if l.eof() {
|
||||
break
|
||||
}
|
||||
r := l.next()
|
||||
if unicode.IsSpace(r) {
|
||||
l.backup()
|
||||
break
|
||||
}
|
||||
if !strings.ContainsRune("0123456789.", r) {
|
||||
l.emitError("bad number")
|
||||
}
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case '~':
|
||||
l.emit(tFUZZY)
|
||||
case '^':
|
||||
l.emit(tBOOST)
|
||||
}
|
||||
return lexDefault
|
||||
}
|
||||
621
vendor/github.com/bcampbell/qs/parse.go
generated
vendored
Normal file
621
vendor/github.com/bcampbell/qs/parse.go
generated
vendored
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
package qs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/blevesearch/bleve"
|
||||
"github.com/blevesearch/bleve/search/query"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParseError is the error type returned by Parse()
|
||||
type ParseError struct {
|
||||
// Pos is the character position where the error occured
|
||||
Pos int
|
||||
// Msg is a description of the error
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (pe ParseError) Error() string { return fmt.Sprintf("%d: %s", pe.Pos, pe.Msg) }
|
||||
|
||||
type OpType int
|
||||
|
||||
const (
|
||||
OR OpType = 0
|
||||
AND = 1
|
||||
)
|
||||
|
||||
type Parser struct {
|
||||
tokens []token
|
||||
pos int
|
||||
// DefaultOp is used when no explict OR or AND is present
|
||||
// ie: foo bar => foo OR bar | foo AND bar
|
||||
// TODO: not sure AND/OR is the right terminology (but it's what others use)
|
||||
// Doesn't actually use OR or AND. AND is treated as an implied '+' prefix
|
||||
DefaultOp OpType
|
||||
|
||||
// Loc is the location to use for parsing dates in range queries.
|
||||
// If nil, UTC is assumed.
|
||||
Loc *time.Location
|
||||
}
|
||||
|
||||
// context is used to hold settings active within a given scope during parsing
|
||||
// (TODO: maybe this should just be absorbed back into the Parser struct instead?)
|
||||
type context struct {
|
||||
// field is the name of the field currently in scope (or "")
|
||||
field string
|
||||
fieldPos int
|
||||
}
|
||||
|
||||
// Parse takes a query string and turns it into a bleve Query.
|
||||
//
|
||||
// Returned errors are type ParseError, which includes the position
|
||||
// of the offending part of the input string.
|
||||
//
|
||||
// BNF(ish) query syntax:
|
||||
// exprList = expr1*
|
||||
// expr1 = expr2 {"OR" expr2}
|
||||
// expr2 = expr3 {"AND" expr3}
|
||||
// expr3 = {"NOT"} expr4
|
||||
// expr4 = {("+"|"-")} expr5
|
||||
// expr5 = {field} part {boost}
|
||||
// part = lit {"~" number} | range | "(" exprList ")"
|
||||
// field = lit ":"
|
||||
// range = ("["|"}") {lit} "TO" {lit} ("]"|"}")
|
||||
// relational = ("<"|">"|"<="|">=") lit
|
||||
// boost = "^" number
|
||||
//
|
||||
// (where lit is a string, quoted string or number)
|
||||
func (p *Parser) Parse(q string) (query.Query, error) {
|
||||
p.tokens = lex(q)
|
||||
ctx := context{field: ""}
|
||||
return p.parseExprList(ctx)
|
||||
}
|
||||
|
||||
// Parse takes a query string and turns it into a bleve Query using
|
||||
// the default Parser.
|
||||
// Returned errors are type ParseError, which includes the position
|
||||
// of the offending part of the input string.
|
||||
func Parse(q string) (query.Query, error) {
|
||||
p := Parser{DefaultOp: OR}
|
||||
return p.Parse(q)
|
||||
}
|
||||
|
||||
// peek looks at the next token without consuming it.
|
||||
// peeks beyond the end of the token stream will return EOF
|
||||
func (p *Parser) peek() token {
|
||||
if p.pos < len(p.tokens) {
|
||||
tok := p.tokens[p.pos]
|
||||
return tok
|
||||
}
|
||||
return token{typ: tEOF}
|
||||
}
|
||||
|
||||
// backup steps back one position in the token stream
|
||||
func (p *Parser) backup() {
|
||||
p.pos -= 1
|
||||
}
|
||||
|
||||
// next fetches the next token in the stream
|
||||
func (p *Parser) next() token {
|
||||
if p.pos < len(p.tokens) {
|
||||
tok := p.tokens[p.pos]
|
||||
p.pos += 1
|
||||
return tok
|
||||
}
|
||||
p.pos += 1 // to make sure backup() works
|
||||
return token{typ: tEOF}
|
||||
}
|
||||
|
||||
// starting point
|
||||
// exprList = expr1*
|
||||
func (p *Parser) parseExprList(ctx context) (query.Query, error) {
|
||||
// <empty>
|
||||
if p.peek().typ == tEOF {
|
||||
return bleve.NewMatchNoneQuery(), nil
|
||||
}
|
||||
|
||||
must := []query.Query{}
|
||||
mustNot := []query.Query{}
|
||||
should := []query.Query{}
|
||||
|
||||
for {
|
||||
tok := p.peek()
|
||||
if tok.typ == tEOF {
|
||||
break
|
||||
}
|
||||
// slightly kludgy...
|
||||
if tok.typ == tRPAREN {
|
||||
break
|
||||
}
|
||||
|
||||
prefix, q, err := p.parseExpr1(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch prefix {
|
||||
case tPLUS:
|
||||
must = append(must, q)
|
||||
case tMINUS:
|
||||
mustNot = append(mustNot, q)
|
||||
default:
|
||||
if p.DefaultOp == AND {
|
||||
must = append(must, q)
|
||||
} else { // OR
|
||||
should = append(should, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// some obvious shortcuts
|
||||
total := len(must) + len(mustNot) + len(should)
|
||||
if total == 0 {
|
||||
return bleve.NewMatchNoneQuery(), nil
|
||||
}
|
||||
if total == 1 && len(must) == 1 {
|
||||
return must[0], nil
|
||||
}
|
||||
if total == 1 && len(should) == 1 {
|
||||
return should[0], nil
|
||||
}
|
||||
|
||||
// no shortcuts - go with the full-fat version
|
||||
q := bleve.NewBooleanQuery()
|
||||
if len(must) > 0 {
|
||||
q.AddMust(must...)
|
||||
}
|
||||
if len(should) > 0 {
|
||||
q.AddShould(should...)
|
||||
}
|
||||
if len(mustNot) > 0 {
|
||||
q.AddMustNot(mustNot...)
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// parseExpr1 handles OR expressions
|
||||
//
|
||||
// expr1 = expr2 {"OR" expr2}
|
||||
func (p *Parser) parseExpr1(ctx context) (tokType, query.Query, error) {
|
||||
|
||||
queries := []query.Query{}
|
||||
prefixes := []tokType{}
|
||||
|
||||
for {
|
||||
prefix, q, err := p.parseExpr2(ctx)
|
||||
if err != nil {
|
||||
return tEOF, nil, err
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, prefix)
|
||||
queries = append(queries, q)
|
||||
|
||||
tok := p.next()
|
||||
if tok.typ != tOR {
|
||||
p.backup()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// let single, non-OR expressions bubble upward, prefix intact
|
||||
if len(queries) == 1 {
|
||||
return prefixes[0], queries[0], nil
|
||||
}
|
||||
|
||||
// KLUDGINESS - prefixes on terms in OR expressions
|
||||
// we'll ignore "+" and treat "-" as NOT
|
||||
// eg:
|
||||
// `+alice OR -bob OR chuck` => `alice OR (NOT bob) OR chuck`
|
||||
for i, _ := range queries {
|
||||
if prefixes[i] == tMINUS {
|
||||
q := bleve.NewBooleanQuery()
|
||||
q.AddMustNot(queries[i])
|
||||
queries[i] = q
|
||||
}
|
||||
}
|
||||
|
||||
return tEOF, bleve.NewDisjunctionQuery(queries...), nil
|
||||
}
|
||||
|
||||
// parseExpr2 handles AND expressions
|
||||
//
|
||||
// expr2 = expr3 {"AND" expr3}
|
||||
func (p *Parser) parseExpr2(ctx context) (tokType, query.Query, error) {
|
||||
|
||||
queries := []query.Query{}
|
||||
prefixes := []tokType{}
|
||||
|
||||
for {
|
||||
prefix, q, err := p.parseExpr3(ctx)
|
||||
if err != nil {
|
||||
return tEOF, nil, err
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, prefix)
|
||||
queries = append(queries, q)
|
||||
|
||||
tok := p.next()
|
||||
if tok.typ != tAND {
|
||||
p.backup()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// let single, non-AND expressions bubble upward, prefix intact
|
||||
if len(queries) == 1 {
|
||||
return prefixes[0], queries[0], nil
|
||||
}
|
||||
|
||||
// KLUDGINESS - prefixes on terms in AND expressions
|
||||
// we'll ignore "+" and treat "-" as NOT
|
||||
// eg:
|
||||
// `+alice AND -bob AND chuck` => `alice AND (NOT bob) AND chuck`
|
||||
for i, _ := range queries {
|
||||
if prefixes[i] == tMINUS {
|
||||
q := bleve.NewBooleanQuery()
|
||||
q.AddMustNot(queries[i])
|
||||
queries[i] = q
|
||||
}
|
||||
}
|
||||
|
||||
return tEOF, bleve.NewConjunctionQuery(queries...), nil
|
||||
}
|
||||
|
||||
// parseExpr3 handles NOT expressions
|
||||
//
|
||||
// expr3 = {"NOT"} expr4
|
||||
func (p *Parser) parseExpr3(ctx context) (tokType, query.Query, error) {
|
||||
|
||||
tok := p.next()
|
||||
if tok.typ != tNOT {
|
||||
p.backup()
|
||||
// just let the lower, non-NOT expression bubble up with its prefix
|
||||
return p.parseExpr4(ctx)
|
||||
}
|
||||
|
||||
prefix, q, err := p.parseExpr4(ctx)
|
||||
if err != nil {
|
||||
return tEOF, nil, err
|
||||
}
|
||||
|
||||
// KLUDGINESS - prefixes on terms in NOT expressions:
|
||||
// `NOT -bob` => `bob`
|
||||
// `NOT +bob` => `NOT bob`
|
||||
if prefix != tMINUS {
|
||||
q2 := bleve.NewBooleanQuery()
|
||||
q2.AddMustNot(q)
|
||||
q = q2
|
||||
}
|
||||
return tEOF, q, nil
|
||||
}
|
||||
|
||||
// Here's where all the prefix-bubbling-up begins...
|
||||
// expr4 = {("+"|"-")} expr5
|
||||
func (p *Parser) parseExpr4(ctx context) (tokType, query.Query, error) {
|
||||
var prefix tokType
|
||||
tok := p.next()
|
||||
switch tok.typ {
|
||||
case tMINUS, tPLUS:
|
||||
prefix = tok.typ
|
||||
default:
|
||||
p.backup()
|
||||
prefix = tEOF
|
||||
}
|
||||
|
||||
q, err := p.parseExpr5(ctx)
|
||||
return prefix, q, err
|
||||
|
||||
}
|
||||
|
||||
// expr5 = {field} part {boost}
|
||||
func (p *Parser) parseExpr5(ctx context) (query.Query, error) {
|
||||
|
||||
fldpos := p.peek().pos
|
||||
fld, err := p.parseField()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fld != "" {
|
||||
if ctx.field != "" {
|
||||
return nil, ParseError{fldpos, fmt.Sprintf("'%s:' clashes with '%s:'", fld, ctx.field)}
|
||||
}
|
||||
ctx.field = fld
|
||||
ctx.fieldPos = fldpos
|
||||
}
|
||||
|
||||
q, err := p.parsePart(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// parse (optional) suffix
|
||||
boostpos := p.peek().pos
|
||||
boost, err := p.parseBoostSuffix()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if boost > 0 {
|
||||
if boostable, ok := q.(query.BoostableQuery); ok {
|
||||
boostable.SetBoost(boost)
|
||||
} else {
|
||||
return nil, ParseError{boostpos, "can't specify a boost value here"}
|
||||
}
|
||||
}
|
||||
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// part = lit {"~" number} | range | "(" exprList ")"
|
||||
func (p *Parser) parsePart(ctx context) (query.Query, error) {
|
||||
|
||||
tok := p.next()
|
||||
|
||||
// lit
|
||||
if tok.typ == tLITERAL {
|
||||
var q query.Query
|
||||
if strings.ContainsAny(tok.val, "*?") {
|
||||
q = bleve.NewWildcardQuery(tok.val)
|
||||
} else {
|
||||
if p.peek().typ == tFUZZY {
|
||||
fuzziness, err := p.parseFuzzySuffix()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fuzz := bleve.NewFuzzyQuery(tok.val)
|
||||
fuzz.SetFuzziness(fuzziness)
|
||||
q = fuzz
|
||||
} else {
|
||||
q = bleve.NewMatchPhraseQuery(tok.val)
|
||||
}
|
||||
}
|
||||
if ctx.field != "" {
|
||||
if fieldable, ok := q.(query.FieldableQuery); ok {
|
||||
fieldable.SetField(ctx.field)
|
||||
} else {
|
||||
return nil, ParseError{ctx.fieldPos, "unexpected field"}
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
if tok.typ == tQUOTED {
|
||||
// strip quotes (ugh)
|
||||
txt := string(tok.val[1 : len(tok.val)-1])
|
||||
/*
|
||||
if strings.ContainsAny(txt, "*?") {
|
||||
return nil, ParseError{tok.pos, "wildcards not supported in phrases"}
|
||||
}
|
||||
*/
|
||||
q := bleve.NewMatchPhraseQuery(txt)
|
||||
if ctx.field != "" {
|
||||
q.SetField(ctx.field)
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// | "(" exprList ")"
|
||||
if tok.typ == tLPAREN {
|
||||
q, err := p.parseExprList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok = p.next()
|
||||
if tok.typ != tRPAREN {
|
||||
return nil, ParseError{tok.pos, "missing )"}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// | range
|
||||
if tok.typ == tLSQUARE || tok.typ == tLBRACE {
|
||||
p.backup()
|
||||
q, err := p.parseRange(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// | relational
|
||||
if tok.typ == tGREATER || tok.typ == tLESS {
|
||||
p.backup()
|
||||
q, err := p.parseRelational(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
if tok.typ == tERROR {
|
||||
return nil, ParseError{tok.pos, tok.val}
|
||||
}
|
||||
|
||||
return nil, ParseError{tok.pos, fmt.Sprintf("unexpected %s", tok.val)}
|
||||
}
|
||||
|
||||
// returns >0 if there is a value given
|
||||
func (p *Parser) parseBoostSuffix() (float64, error) {
|
||||
tok := p.next()
|
||||
if tok.typ != tBOOST {
|
||||
p.backup()
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
v := tok.val[1:]
|
||||
if v == "" {
|
||||
return 1.0, nil
|
||||
}
|
||||
boost, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return 0, ParseError{tok.pos, "bad boost value"}
|
||||
}
|
||||
|
||||
return boost, nil
|
||||
}
|
||||
|
||||
//
|
||||
func (p *Parser) parseFuzzySuffix() (int, error) {
|
||||
tok := p.next()
|
||||
if tok.typ != tFUZZY {
|
||||
return 0, ParseError{tok.pos, "expected ~"}
|
||||
}
|
||||
|
||||
v := tok.val[1:]
|
||||
if v == "" {
|
||||
return 1, nil // Default fuzziness is 1
|
||||
}
|
||||
fuzz, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, ParseError{tok.pos, "bad fuzziness value"}
|
||||
}
|
||||
|
||||
return fuzz, nil
|
||||
}
|
||||
|
||||
// parse (optional) field specifier
|
||||
// [ lit ":" ]
|
||||
// returns field name or "" if not a field
|
||||
func (p *Parser) parseField() (string, error) {
|
||||
tok := p.next()
|
||||
if tok.typ != tLITERAL {
|
||||
// not a field
|
||||
p.backup()
|
||||
return "", nil
|
||||
}
|
||||
field := tok.val
|
||||
|
||||
tok = p.next()
|
||||
if tok.typ != tCOLON {
|
||||
// oop, not a field after all!
|
||||
p.backup()
|
||||
p.backup()
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return field, nil // it's OK
|
||||
}
|
||||
|
||||
// range = ("["|"}") {lit} "TO" {lit} ("]"|"}")
|
||||
func (p *Parser) parseRange(ctx context) (query.Query, error) {
|
||||
|
||||
var minVal, maxVal string
|
||||
var minInclusive, maxInclusive bool
|
||||
openTok := p.next()
|
||||
switch openTok.typ {
|
||||
case tLSQUARE:
|
||||
minInclusive = true
|
||||
case tLBRACE:
|
||||
minInclusive = false
|
||||
default:
|
||||
return nil, ParseError{openTok.pos, "expected range"}
|
||||
}
|
||||
|
||||
tok := p.next()
|
||||
switch tok.typ {
|
||||
case tLITERAL:
|
||||
minVal = tok.val
|
||||
case tQUOTED:
|
||||
minVal = tok.val[1 : len(tok.val)-1]
|
||||
case tTO:
|
||||
p.backup()
|
||||
// empty start
|
||||
default:
|
||||
return nil, ParseError{tok.pos, fmt.Sprintf("unexpected %s", tok.val)}
|
||||
}
|
||||
|
||||
tok = p.next()
|
||||
if tok.typ != tTO {
|
||||
return nil, ParseError{tok.pos, "expected TO"}
|
||||
}
|
||||
|
||||
tok = p.next()
|
||||
switch tok.typ {
|
||||
case tLITERAL:
|
||||
maxVal = tok.val
|
||||
case tQUOTED:
|
||||
maxVal = tok.val[1 : len(tok.val)-1]
|
||||
case tRSQUARE:
|
||||
p.backup() // empty end value
|
||||
case tRBRACE:
|
||||
p.backup() // empty end value
|
||||
default:
|
||||
return nil, ParseError{tok.pos, fmt.Sprintf("unexpected %s", tok.val)}
|
||||
}
|
||||
|
||||
closeTok := p.next()
|
||||
switch closeTok.typ {
|
||||
case tRSQUARE:
|
||||
maxInclusive = true
|
||||
case tRBRACE:
|
||||
maxInclusive = false
|
||||
default:
|
||||
return nil, ParseError{closeTok.pos, "expected ] or }"}
|
||||
}
|
||||
|
||||
rp := newRangeParams(minVal, maxVal, minInclusive, maxInclusive, p.Loc)
|
||||
q, err := rp.generate()
|
||||
if err != nil {
|
||||
return nil, ParseError{openTok.pos, err.Error()}
|
||||
}
|
||||
if ctx.field != "" {
|
||||
if fieldable, ok := q.(query.FieldableQuery); ok {
|
||||
fieldable.SetField(ctx.field)
|
||||
} else {
|
||||
return nil, ParseError{ctx.fieldPos, "unexpected field"}
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// parseRelational handles greaterthan/lessthan etc...
|
||||
// Implemented as a range.
|
||||
// relational = ("<"|">"|"<="|">=") lit
|
||||
func (p *Parser) parseRelational(ctx context) (query.Query, error) {
|
||||
|
||||
var minVal, maxVal string
|
||||
var minInclusive, maxInclusive bool
|
||||
|
||||
rel := p.next()
|
||||
if rel.typ != tGREATER && rel.typ != tLESS {
|
||||
return nil, ParseError{rel.pos, "expected > or <"}
|
||||
}
|
||||
|
||||
eq := p.next()
|
||||
if eq.typ != tEQUAL {
|
||||
p.backup()
|
||||
}
|
||||
|
||||
var val string
|
||||
tok := p.next()
|
||||
switch tok.typ {
|
||||
case tLITERAL:
|
||||
val = tok.val
|
||||
case tQUOTED:
|
||||
val = tok.val[1 : len(tok.val)-1]
|
||||
default:
|
||||
return nil, ParseError{tok.pos, fmt.Sprintf("unexpected %s", tok.val)}
|
||||
}
|
||||
|
||||
if rel.typ == tGREATER {
|
||||
minVal = val
|
||||
minInclusive = (eq.typ == tEQUAL)
|
||||
} else { // if rel.typ == tLESS
|
||||
maxVal = val
|
||||
maxInclusive = (eq.typ == tEQUAL)
|
||||
}
|
||||
|
||||
rp := newRangeParams(minVal, maxVal, minInclusive, maxInclusive, p.Loc)
|
||||
q, err := rp.generate()
|
||||
if err != nil {
|
||||
return nil, ParseError{rel.pos, err.Error()}
|
||||
}
|
||||
if ctx.field != "" {
|
||||
if fieldable, ok := q.(query.FieldableQuery); ok {
|
||||
fieldable.SetField(ctx.field)
|
||||
} else {
|
||||
return nil, ParseError{ctx.fieldPos, "unexpected field"}
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
117
vendor/github.com/bcampbell/qs/range.go
generated
vendored
Normal file
117
vendor/github.com/bcampbell/qs/range.go
generated
vendored
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package qs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/blevesearch/bleve"
|
||||
"github.com/blevesearch/bleve/search/query"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// helper stuff for setting up range queries
|
||||
|
||||
// returns time, precision
|
||||
// TODO: support other precisions? "YYYY-MM", "YYYY-MM-DDThh:mm:ss" etc?
|
||||
func parseTime(in string, loc *time.Location) (time.Time, string) {
|
||||
t, err := time.ParseInLocation("2006-01-02", in, loc)
|
||||
if err != nil {
|
||||
return time.Time{}, ""
|
||||
}
|
||||
return t, "day"
|
||||
}
|
||||
|
||||
type rangeParams struct {
|
||||
min, max *string
|
||||
minInclusive, maxInclusive *bool
|
||||
// need location for parsing times
|
||||
loc *time.Location
|
||||
}
|
||||
|
||||
func newRangeParams(minVal, maxVal string, minInc, maxInc bool, loc *time.Location) *rangeParams {
|
||||
rp := &rangeParams{}
|
||||
if minVal != "" {
|
||||
rp.min = &minVal
|
||||
rp.minInclusive = &minInc
|
||||
}
|
||||
if maxVal != "" {
|
||||
rp.max = &maxVal
|
||||
rp.maxInclusive = &maxInc
|
||||
}
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
rp.loc = loc
|
||||
return rp
|
||||
}
|
||||
|
||||
func (rp *rangeParams) numericArgs() (bool, *float64, *float64) {
|
||||
var f1, f2 *float64
|
||||
if rp.min != nil {
|
||||
f, err := strconv.ParseFloat(*rp.min, 64)
|
||||
if err != nil {
|
||||
return false, nil, nil
|
||||
}
|
||||
f1 = &f
|
||||
}
|
||||
if rp.max != nil {
|
||||
f, err := strconv.ParseFloat(*rp.max, 64)
|
||||
if err != nil {
|
||||
return false, nil, nil
|
||||
}
|
||||
f2 = &f
|
||||
}
|
||||
return true, f1, f2
|
||||
}
|
||||
|
||||
func (rp *rangeParams) dateArgs() (bool, time.Time, time.Time) {
|
||||
var truthy bool = true
|
||||
var falsey bool = false
|
||||
var t1, t2 time.Time
|
||||
var prec string
|
||||
if rp.min != nil {
|
||||
t1, prec = parseTime(*rp.min, rp.loc)
|
||||
switch prec {
|
||||
case "day":
|
||||
if !*rp.minInclusive {
|
||||
// add 1 day and make inclusive
|
||||
t1 = t1.AddDate(0, 0, 1)
|
||||
rp.minInclusive = &truthy
|
||||
}
|
||||
default:
|
||||
return false, t1, t2
|
||||
}
|
||||
}
|
||||
if rp.max != nil {
|
||||
t2, prec = parseTime(*rp.max, rp.loc)
|
||||
switch prec {
|
||||
case "day":
|
||||
if *rp.maxInclusive {
|
||||
// add 1 day and change to exclusive
|
||||
t2 = t2.AddDate(0, 0, 1)
|
||||
rp.maxInclusive = &falsey
|
||||
}
|
||||
default:
|
||||
return false, t1, t2
|
||||
}
|
||||
}
|
||||
|
||||
return true, t1, t2
|
||||
}
|
||||
|
||||
// try and build a query from the given params
|
||||
func (rp *rangeParams) generate() (query.Query, error) {
|
||||
if rp.min == nil && rp.max == nil {
|
||||
return nil, fmt.Errorf("empty range")
|
||||
}
|
||||
isNumeric, f1, f2 := rp.numericArgs()
|
||||
if isNumeric {
|
||||
return bleve.NewNumericRangeInclusiveQuery(f1, f2, rp.minInclusive, rp.maxInclusive), nil
|
||||
}
|
||||
|
||||
isDate, t1, t2 := rp.dateArgs()
|
||||
if isDate {
|
||||
return bleve.NewDateRangeInclusiveQuery(t1, t2, rp.minInclusive, rp.maxInclusive), nil
|
||||
}
|
||||
return nil, fmt.Errorf("not numeric")
|
||||
|
||||
}
|
||||
202
vendor/github.com/bcampbell/qs/syntax.md
generated
vendored
Normal file
202
vendor/github.com/bcampbell/qs/syntax.md
generated
vendored
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# Query String Syntax
|
||||
|
||||
## Basics
|
||||
|
||||
Single words are treated as simple terms. The results will include
|
||||
documents containing those terms, eg:
|
||||
|
||||
embiggen
|
||||
cromulent
|
||||
|
||||
Terms are separated by spaces, so:
|
||||
|
||||
navel orange
|
||||
|
||||
Will match both documents containing "navel" and documents containing "orange",
|
||||
which is probably not what was intended. The user was probably looking for
|
||||
documents about "navel oranges" rather than those about "orange navels".
|
||||
|
||||
To search for phrases - multiple words, matched in order - enclose them in double quotes.
|
||||
|
||||
For example:
|
||||
|
||||
"navel orange"
|
||||
|
||||
## Boolean Operators
|
||||
|
||||
### `OR`
|
||||
|
||||
The `OR` operator returns documents matched by terms on either side of it.
|
||||
It is the default operator, meaning that the following two queries are
|
||||
considered to be equivalent:
|
||||
|
||||
orange OR "navel orange"
|
||||
orange "navel orange"
|
||||
|
||||
Note that the query parser can be set up to use `AND` as the default operator instead of `OR`.
|
||||
|
||||
### `AND`
|
||||
|
||||
'`AND`' returns documents which match the terms on *both* sides.
|
||||
|
||||
orange AND citrus
|
||||
|
||||
### `+`
|
||||
|
||||
'`+`' is the 'required' operator. A term prefixed with '`+`' must exist in matching
|
||||
documents.
|
||||
|
||||
For example, to return documents definitely containing "orange" and maybe also
|
||||
containing "fruit":
|
||||
|
||||
fruit +orange
|
||||
|
||||
|
||||
### `NOT`
|
||||
|
||||
`NOT` excludes documents that match the following term.
|
||||
|
||||
For example, to match documents containing "orange" but not "paint":
|
||||
|
||||
orange NOT paint
|
||||
|
||||
|
||||
### `-`
|
||||
|
||||
Prefixing a term with '`-`' will "prohibit" it.
|
||||
|
||||
For example, to match documents containing "orange" but not "paint":
|
||||
|
||||
orange -paint
|
||||
|
||||
|
||||
### Precedence
|
||||
|
||||
`-`, `+` and `NOT` take precedence over `AND`, which takes precedence over `OR`.
|
||||
|
||||
For example,
|
||||
|
||||
lemon OR orange AND citrus
|
||||
|
||||
is treated as:
|
||||
|
||||
lemon OR (orange AND citrus)
|
||||
|
||||
|
||||
|
||||
## Field Scoping
|
||||
|
||||
You can control which fields are matched by prefixing the name of a field separated by a colon.
|
||||
|
||||
Examples:
|
||||
|
||||
genus:citrus
|
||||
headline:"How to Make the Perfect Negroni"
|
||||
tags:(fruit OR paint)
|
||||
|
||||
If no field is specified, the default `_all` is used, unless overridden in the index mapping.
|
||||
|
||||
|
||||
## Grouping
|
||||
|
||||
Parentheses can be used to group sub queries.
|
||||
For example:
|
||||
|
||||
(shaddock OR pomelo OR pamplemousse) AND (family:rutaceae AND NOT genus:fortunella) AND colour:(greenish OR yellowish)
|
||||
|
||||
## Wildcards
|
||||
|
||||
Within an individual term, partial matches can be described using wildcard characters:
|
||||
|
||||
`?` to match any single character
|
||||
|
||||
`*` To match and sequence of zero or more characters
|
||||
|
||||
For example:
|
||||
|
||||
qu?ck bro*
|
||||
|
||||
|
||||
## Fuzziness
|
||||
|
||||
A fuzzy query is a term query that matches terms within a given [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance).
|
||||
The edit distance is the number of single-character edits (insertions, deletions or substitutions) allowed.
|
||||
|
||||
To specify a fuzzy query, use the tilde sign (`~`), optionally followed by the edit distance you'll accept.
|
||||
|
||||
|
||||
For example,
|
||||
|
||||
colour~1
|
||||
|
||||
to match "`colour`" or "`color`" (or "`zolour`" or "`colours`"... but not "`colors`", as the `~1` allows only a single character change).
|
||||
|
||||
If no number is specified, the default value is 1, so the following are equivalent:
|
||||
|
||||
wibble~
|
||||
wibble~1
|
||||
|
||||
|
||||
|
||||
## Boosting
|
||||
|
||||
Boosting a term doesn't affect the set of matching documents, but it does
|
||||
indicate that that term should be assigned greater importance when the
|
||||
results are being scored by relevance.
|
||||
|
||||
To boost a term, you use the boost operator '`^`' followed by a number
|
||||
indicating the relative importance of the term.
|
||||
|
||||
For example, in the following query, "grapefruit" should be considered twice
|
||||
as important as "lime":
|
||||
|
||||
grapefruit^2 lime
|
||||
|
||||
You can boost more complex sub queries:
|
||||
|
||||
(grapefruit OR orange)^2 "navel orange"^4 genus:citrus
|
||||
|
||||
|
||||
|
||||
## Ranges
|
||||
|
||||
Inclusive ranges can be described with square braces and `TO`, eg:
|
||||
|
||||
num:[1 TO 5]
|
||||
date:[2010-01-01 TO 2010-01-31]
|
||||
|
||||
Dates must be in `YYYY-MM-DD` form.
|
||||
|
||||
Exclusive ranges are supported using curly braces. So, these are equivalent:
|
||||
|
||||
score:{0 TO 10}
|
||||
score:[1 TO 9]
|
||||
|
||||
|
||||
|
||||
You can mix inclusive and exclusive endpoints, eg:
|
||||
|
||||
pubdate:[2010-01-01 TO 2011-01-01}
|
||||
shoesize:{0 TO 16]
|
||||
byte:[0 TO 256}
|
||||
|
||||
You can have open ranges by leaving off either endpoint:
|
||||
|
||||
pubdate:[2000-01-01 TO ]
|
||||
temp:[TO 100}
|
||||
|
||||
|
||||
NOTE: ranges currently work only on numeric and date fields.
|
||||
|
||||
|
||||
## Relational Operators
|
||||
|
||||
You can perform numeric comparisons using the `>`, `>=`, `<`, and `<=` operators.
|
||||
These are equivalent to using the above range syntax with unbounded ranges.
|
||||
|
||||
For example:
|
||||
|
||||
score:>=100
|
||||
score:[ TO 100]
|
||||
|
||||
|
||||
12
vendor/modules.txt
vendored
12
vendor/modules.txt
vendored
|
|
@ -4,16 +4,19 @@ git.giftfish.de/ston1th/godrop/v2
|
|||
git.giftfish.de/ston1th/jwt/v3
|
||||
# github.com/RoaringBitmap/roaring v0.4.17
|
||||
github.com/RoaringBitmap/roaring
|
||||
# github.com/bcampbell/qs v0.0.0-20161004022730-15caa99abd01
|
||||
github.com/bcampbell/qs
|
||||
# github.com/blevesearch/bleve v0.8.0
|
||||
github.com/blevesearch/bleve
|
||||
github.com/blevesearch/bleve/analysis/analyzer/custom
|
||||
github.com/blevesearch/bleve/analysis/token/edgengram
|
||||
github.com/blevesearch/bleve/analysis/token/lowercase
|
||||
github.com/blevesearch/bleve/analysis/tokenizer/unicode
|
||||
github.com/blevesearch/bleve/document
|
||||
github.com/blevesearch/bleve/mapping
|
||||
github.com/blevesearch/bleve/search/query
|
||||
github.com/blevesearch/bleve/analysis
|
||||
github.com/blevesearch/bleve/analysis/datetime/optional
|
||||
github.com/blevesearch/bleve/document
|
||||
github.com/blevesearch/bleve/index
|
||||
github.com/blevesearch/bleve/index/scorch
|
||||
github.com/blevesearch/bleve/index/store
|
||||
|
|
@ -26,19 +29,18 @@ github.com/blevesearch/bleve/search/collector
|
|||
github.com/blevesearch/bleve/search/facet
|
||||
github.com/blevesearch/bleve/search/highlight
|
||||
github.com/blevesearch/bleve/search/highlight/highlighter/html
|
||||
github.com/blevesearch/bleve/search/query
|
||||
github.com/blevesearch/bleve/size
|
||||
github.com/blevesearch/bleve/analysis/analyzer/standard
|
||||
github.com/blevesearch/bleve/geo
|
||||
github.com/blevesearch/bleve/analysis/datetime/flexible
|
||||
github.com/blevesearch/bleve/numeric
|
||||
github.com/blevesearch/bleve/analysis/analyzer/standard
|
||||
github.com/blevesearch/bleve/search/searcher
|
||||
github.com/blevesearch/bleve/analysis/datetime/flexible
|
||||
github.com/blevesearch/bleve/index/scorch/mergeplan
|
||||
github.com/blevesearch/bleve/index/scorch/segment
|
||||
github.com/blevesearch/bleve/index/scorch/segment/zap
|
||||
github.com/blevesearch/bleve/search/highlight/format/html
|
||||
github.com/blevesearch/bleve/search/highlight/fragmenter/simple
|
||||
github.com/blevesearch/bleve/search/highlight/highlighter/simple
|
||||
github.com/blevesearch/bleve/search/searcher
|
||||
github.com/blevesearch/bleve/analysis/lang/en
|
||||
github.com/blevesearch/bleve/search/scorer
|
||||
github.com/blevesearch/bleve/analysis/token/porter
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue