diff --git a/cmd/search/search.go b/cmd/search/search.go index 87942ff..5b7afcd 100644 --- a/cmd/search/search.go +++ b/cmd/search/search.go @@ -29,7 +29,7 @@ func NewSearchCommand(logger *slog.Logger) *cobra.Command { return fmt.Errorf("failed to open index: %w", err) } searcher := search.NewBlugeSearch(reader) - res, err := searcher.Search(context.Background(), searchterms.MustParse(args[0]), 1) + res, err := searcher.Search(context.Background(), searchterms.MustParse(args[0])) if err != nil { return fmt.Errorf("search failed: %w", err) } diff --git a/pkg/discord/bot.go b/pkg/discord/bot.go index 1269313..6cf471f 100644 --- a/pkg/discord/bot.go +++ b/pkg/discord/bot.go @@ -156,7 +156,7 @@ func NewBot( Options: []*discordgo.ApplicationCommandOption{ { Name: "query", - Description: `Enter a partial quote. Phrase match with "double quotes". Filter with ~publication #s1e01 +10m30s`, + Description: `Phrase match with "quotes". Page with >N e.g. >10. Filter with ~publication #s1e01 +10m30s`, Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true, @@ -406,7 +406,7 @@ func (b *Bot) queryBegin(s *discordgo.Session, i *discordgo.InteractionCreate) { return } - res, err := b.searcher.Search(context.Background(), terms, 0) + res, err := b.searcher.Search(context.Background(), terms) if err != nil { b.logger.Error("Failed to fetch autocomplete options", slog.String("err", err.Error())) return diff --git a/pkg/search/search.go b/pkg/search/search.go index 8728e94..9b9d27a 100644 --- a/pkg/search/search.go +++ b/pkg/search/search.go @@ -18,7 +18,7 @@ const ( ) type Searcher interface { - Search(ctx context.Context, f []searchterms.Term, page int32) ([]model.DialogDocument, error) + Search(ctx context.Context, f []searchterms.Term) ([]model.DialogDocument, error) Get(ctx context.Context, id string) (*model.DialogDocument, error) ListTerms(ctx context.Context, field string) ([]string, error) } @@ -32,7 +32,7 @@ type BlugeSearch struct { } func (b *BlugeSearch) Get(ctx context.Context, id string) (*model.DialogDocument, error) { - q, err := bluge_query.NewBlugeQuery([]searchterms.Term{{Field: "_id", Value: searchterms.String(id), Op: searchterms.CompOpEq}}) + q, _, err := bluge_query.NewBlugeQuery([]searchterms.Term{{Field: "_id", Value: searchterms.String(id), Op: searchterms.CompOpEq}}) if err != nil { return nil, fmt.Errorf("filter was invalid: %w", err) } @@ -50,14 +50,19 @@ func (b *BlugeSearch) Get(ctx context.Context, id string) (*model.DialogDocument return scanDocument(match) } -func (b *BlugeSearch) Search(ctx context.Context, f []searchterms.Term, page int32) ([]model.DialogDocument, error) { +func (b *BlugeSearch) Search(ctx context.Context, f []searchterms.Term) ([]model.DialogDocument, error) { - query, err := bluge_query.NewBlugeQuery(f) + query, offset, err := bluge_query.NewBlugeQuery(f) if err != nil { return nil, err } - req := bluge.NewTopNSearch(PageSize, query).SetFrom(PageSize * int(page)) + setFrom := 0 + if offset != nil { + setFrom = int(*offset) + } + + req := bluge.NewTopNSearch(PageSize, query).SetFrom(setFrom) dmi, err := b.index.Search(ctx, req) if err != nil { diff --git a/pkg/searchterms/bluge_query/bluge.go b/pkg/searchterms/bluge_query/bluge.go index 6ce57dd..f8b1781 100644 --- a/pkg/searchterms/bluge_query/bluge.go +++ b/pkg/searchterms/bluge_query/bluge.go @@ -6,19 +6,43 @@ import ( "github.com/warmans/tvgif/pkg/search/mapping" "github.com/warmans/tvgif/pkg/search/model" "github.com/warmans/tvgif/pkg/searchterms" + "github.com/warmans/tvgif/pkg/util" "math" + "slices" "strings" "time" ) -func NewBlugeQuery(terms []searchterms.Term) (bluge.Query, error) { +func extractOffset(terms []searchterms.Term) ([]searchterms.Term, *int64) { + offsetIdx := slices.IndexFunc(terms, func(val searchterms.Term) bool { + return val.Field == "offset" + }) + if offsetIdx == -1 { + return terms, nil + } + var offset *int64 + if offsetIdx >= 0 { + if offsetVal := terms[offsetIdx].Value.Value().(int64); offsetVal >= 0 { + offset = util.ToPtr(offsetVal) + } + terms = append(terms[:offsetIdx], terms[offsetIdx+1:]...) + } + return terms, offset +} + +func NewBlugeQuery(terms []searchterms.Term) (bluge.Query, *int64, error) { + + // the paging/offset is included in the filter string but is not a filter so it needs to be + // extracted. + filteredTerms, offset := extractOffset(terms) + q := &BlugeQuery{q: bluge.NewBooleanQuery()} - for _, v := range terms { + for _, v := range filteredTerms { if err := q.And(v); err != nil { - return nil, err + return nil, nil, err } } - return q.q, nil + return q.q, offset, nil } type BlugeQuery struct { diff --git a/pkg/searchterms/bluge_query/bluge_test.go b/pkg/searchterms/bluge_query/bluge_test.go new file mode 100644 index 0000000..5a81c8d --- /dev/null +++ b/pkg/searchterms/bluge_query/bluge_test.go @@ -0,0 +1,105 @@ +package bluge_query + +import ( + "github.com/warmans/tvgif/pkg/searchterms" + "github.com/warmans/tvgif/pkg/util" + "reflect" + "testing" +) + +func Test_extractOffset(t *testing.T) { + tests := []struct { + name string + terms []searchterms.Term + want []searchterms.Term + want1 *int64 + }{ + { + name: "empty terms returns empty, nil", + terms: make([]searchterms.Term, 0), + want: make([]searchterms.Term, 0), + want1: nil, + }, + { + name: "no offset returns original terms", + terms: []searchterms.Term{ + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + {Field: "series", Value: searchterms.Int(1), Op: searchterms.CompOpEq}, + }, + want: []searchterms.Term{ + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + {Field: "series", Value: searchterms.Int(1), Op: searchterms.CompOpEq}, + }, + want1: nil, + }, { + name: "no offset returns original terms", + terms: []searchterms.Term{ + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + {Field: "series", Value: searchterms.Int(1), Op: searchterms.CompOpEq}, + }, + want: []searchterms.Term{ + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + {Field: "series", Value: searchterms.Int(1), Op: searchterms.CompOpEq}, + }, + want1: nil, + }, { + name: "offset is extracted from last position", + terms: []searchterms.Term{ + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + {Field: "offset", Value: searchterms.Int(10), Op: searchterms.CompOpEq}, + }, + want: []searchterms.Term{ + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + }, + want1: util.ToPtr(int64(10)), + }, { + name: "offset is extracted from first position", + terms: []searchterms.Term{ + {Field: "offset", Value: searchterms.Int(10), Op: searchterms.CompOpEq}, + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + }, + want: []searchterms.Term{ + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + }, + want1: util.ToPtr(int64(10)), + }, { + name: "offset is extracted from middle position", + terms: []searchterms.Term{ + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "offset", Value: searchterms.Int(10), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + }, + want: []searchterms.Term{ + {Field: "actor", Value: searchterms.String("steve"), Op: searchterms.CompOpEq}, + {Field: "publication", Value: searchterms.String("xfm"), Op: searchterms.CompOpEq}, + }, + want1: util.ToPtr(int64(10)), + }, { + name: "offset is only filter", + terms: []searchterms.Term{ + {Field: "offset", Value: searchterms.Int(10), Op: searchterms.CompOpEq}, + }, + want: []searchterms.Term{}, + want1: util.ToPtr(int64(10)), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := extractOffset(tt.terms) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("extractOffset() got = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(got1, tt.want1) { + t.Errorf("extractOffset() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} diff --git a/pkg/searchterms/parser.go b/pkg/searchterms/parser.go index b901aa0..f1082db 100644 --- a/pkg/searchterms/parser.go +++ b/pkg/searchterms/parser.go @@ -149,6 +149,20 @@ func (p *parser) parseInner() ([]*Term, error) { Value: Duration(ts), Op: CompOpGe, }}, nil + case tagOffset: + offsetText, err := p.requireNext(tagInt, tagEOF) + if err != nil { + return nil, err + } + intVal, err := strconv.ParseInt(offsetText.lexeme, 10, 64) + if err != nil { + return nil, fmt.Errorf("offset was not a number: %w", err) + } + return []*Term{{ + Field: "offset", + Value: Int(intVal), + Op: CompOpEq, + }}, nil default: return nil, errors.Errorf("unexpected token '%s'", tok) } diff --git a/pkg/searchterms/parser_test.go b/pkg/searchterms/parser_test.go index 7b44ac2..03a4348 100644 --- a/pkg/searchterms/parser_test.go +++ b/pkg/searchterms/parser_test.go @@ -81,9 +81,16 @@ func TestMustParse(t *testing.T) { {Field: "start_timestamp", Value: Duration(time.Minute*10 + time.Second*30), Op: CompOpGe}, }, }, + { + name: "parse offset", + args: args{s: `>20`}, + want: []Term{ + {Field: "offset", Value: Int(20), Op: CompOpEq}, + }, + }, { name: "parse all", - args: args{s: `@steve ~xfm #s1 +30m "man alive" karl`}, + args: args{s: `@steve ~xfm #s1 +30m "man alive" karl >10`}, want: []Term{ {Field: "actor", Value: String("steve"), Op: CompOpEq}, {Field: "publication", Value: String("xfm"), Op: CompOpEq}, @@ -91,6 +98,7 @@ func TestMustParse(t *testing.T) { {Field: "start_timestamp", Value: Duration(time.Minute * 30), Op: CompOpGe}, {Field: "content", Value: String("man alive"), Op: CompOpEq}, {Field: "content", Value: String("karl"), Op: CompOpFuzzyLike}, + {Field: "offset", Value: Int(10), Op: CompOpEq}, }, }, } diff --git a/pkg/searchterms/scanner.go b/pkg/searchterms/scanner.go index 74b456b..e44dc5e 100644 --- a/pkg/searchterms/scanner.go +++ b/pkg/searchterms/scanner.go @@ -15,6 +15,7 @@ const ( tagPublication = "~" tagId = "#" tagTimestamp = "+" + tagOffset = ">" tagQuotedString = "QUOTED_STRING" tagWord = "WORD" @@ -82,6 +83,8 @@ func (s *scanner) next() (token, error) { return s.emit(tagId), nil case '+': return s.emit(tagTimestamp), nil + case '>': + return s.emit(tagOffset), nil case '"': return s.scanString() default: diff --git a/pkg/searchterms/scanner_test.go b/pkg/searchterms/scanner_test.go index 3765ebc..78b3b44 100644 --- a/pkg/searchterms/scanner_test.go +++ b/pkg/searchterms/scanner_test.go @@ -76,6 +76,14 @@ func TestScan(t *testing.T) { want: []token{{tag: tagTimestamp, lexeme: "+"}, {tag: tagInt, lexeme: "10"}, {tag: tagWord, lexeme: "m"}, {tag: tagEOF}}, wantErr: false, }, + { + name: "scan offset", + args: args{ + str: `>10`, + }, + want: []token{{tag: tagOffset, lexeme: ">"}, {tag: tagInt, lexeme: "10"}, {tag: tagEOF}}, + wantErr: false, + }, { name: "scan everything", args: args{