mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-12-07 14:09:47 +00:00
List of currently supported filters: - `is:open` (or `-is:closed`) - `is:closed` (or `-is:open`) - `is:all` - `author:<username>` - `assignee:<username>` - `review:<username>` - `mentions:<username>` - `modified:[>|<]<date>`, where `<date>` is the last update date. - `sort:<by>:[asc|desc]`, where `<by>` is among - created - comments - updated - deadline Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9109 Reviewed-by: 0ko <0ko@noreply.codeberg.org> Reviewed-by: Ellen Εμιλία Άννα Zscheile <fogti@noreply.codeberg.org> Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: Shiny Nematoda <snematoda.751k2@aleeas.com> Co-committed-by: Shiny Nematoda <snematoda.751k2@aleeas.com>
271 lines
5.5 KiB
Go
271 lines
5.5 KiB
Go
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package internal
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"forgejo.org/models/user"
|
|
"forgejo.org/modules/log"
|
|
"forgejo.org/modules/optional"
|
|
)
|
|
|
|
type BoolOpt int
|
|
|
|
const (
|
|
BoolOptMust BoolOpt = iota
|
|
BoolOptShould
|
|
BoolOptNot
|
|
)
|
|
|
|
type Token struct {
|
|
Term string
|
|
Kind BoolOpt
|
|
Fuzzy bool
|
|
}
|
|
|
|
// Helper function to check if the term starts with a prefix.
|
|
func (tk *Token) IsOf(prefix string) bool {
|
|
return strings.HasPrefix(tk.Term, prefix) && len(tk.Term) > len(prefix)
|
|
}
|
|
|
|
func (tk *Token) ParseIssueReference() (int64, error) {
|
|
term := tk.Term
|
|
if len(term) > 1 && (term[0] == '#' || term[0] == '!') {
|
|
term = term[1:]
|
|
}
|
|
return strconv.ParseInt(term, 10, 64)
|
|
}
|
|
|
|
type Tokenizer struct {
|
|
in *strings.Reader
|
|
}
|
|
|
|
func (t *Tokenizer) next() (tk Token, err error) {
|
|
var (
|
|
sb strings.Builder
|
|
r rune
|
|
)
|
|
tk.Kind = BoolOptShould
|
|
tk.Fuzzy = true
|
|
|
|
// skip all leading white space
|
|
for {
|
|
if r, _, err = t.in.ReadRune(); err != nil || r != ' ' {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
return tk, err
|
|
}
|
|
|
|
// check for +/- op, increment to the next rune in both cases
|
|
switch r {
|
|
case '+':
|
|
tk.Kind = BoolOptMust
|
|
r, _, err = t.in.ReadRune()
|
|
case '-':
|
|
tk.Kind = BoolOptNot
|
|
r, _, err = t.in.ReadRune()
|
|
}
|
|
if err != nil {
|
|
return tk, err
|
|
}
|
|
|
|
// parse the string, escaping special characters
|
|
for esc := false; err == nil; r, _, err = t.in.ReadRune() {
|
|
if esc {
|
|
if !strings.ContainsRune("+-\\\"", r) {
|
|
sb.WriteRune('\\')
|
|
}
|
|
sb.WriteRune(r)
|
|
esc = false
|
|
continue
|
|
}
|
|
switch r {
|
|
case '\\':
|
|
esc = true
|
|
case '"':
|
|
if !tk.Fuzzy {
|
|
goto nextEnd
|
|
}
|
|
tk.Fuzzy = false
|
|
case ' ', '\t':
|
|
if tk.Fuzzy {
|
|
goto nextEnd
|
|
}
|
|
sb.WriteRune(r)
|
|
default:
|
|
sb.WriteRune(r)
|
|
}
|
|
}
|
|
nextEnd:
|
|
|
|
tk.Term = sb.String()
|
|
if err == io.EOF {
|
|
err = nil
|
|
} // do not consider EOF as an error at the end
|
|
return tk, err
|
|
}
|
|
|
|
type userFilter int
|
|
|
|
const (
|
|
userFilterAuthor userFilter = iota
|
|
userFilterAssign
|
|
userFilterMention
|
|
userFilterReview
|
|
)
|
|
|
|
// Parses the keyword and sets the
|
|
func (o *SearchOptions) WithKeyword(ctx context.Context, keyword string) (err error) {
|
|
if keyword == "" {
|
|
return nil
|
|
}
|
|
|
|
in := strings.NewReader(keyword)
|
|
it := Tokenizer{in: in}
|
|
|
|
var (
|
|
tokens []Token
|
|
userNames []string
|
|
userFilter []userFilter
|
|
)
|
|
|
|
for token, err := it.next(); err == nil; token, err = it.next() {
|
|
if token.Term == "" {
|
|
continue
|
|
}
|
|
|
|
// For an exact search (wrapped in quotes)
|
|
// push the token to the list.
|
|
if !token.Fuzzy {
|
|
tokens = append(tokens, token)
|
|
continue
|
|
}
|
|
|
|
// Otherwise, try to match the token with a preset filter.
|
|
switch {
|
|
// is:open => open & -is:open => closed
|
|
case token.Term == "is:open":
|
|
o.IsClosed = optional.Some(token.Kind == BoolOptNot)
|
|
|
|
// Similarly, is:closed & -is:closed
|
|
case token.Term == "is:closed":
|
|
o.IsClosed = optional.Some(token.Kind != BoolOptNot)
|
|
|
|
// The rest of the presets MUST NOT be a negation.
|
|
case token.Kind == BoolOptNot:
|
|
tokens = append(tokens, token)
|
|
|
|
// is:all: Do not consider -is:all.
|
|
case token.Term == "is:all":
|
|
o.IsClosed = optional.None[bool]()
|
|
|
|
// sort:<by>:[ asc | desc ],
|
|
case token.IsOf("sort:"):
|
|
o.SortBy = parseSortBy(token.Term[5:])
|
|
|
|
// modified:[ < | > ]<date>.
|
|
// for example, modified:>2025-08-29
|
|
case token.IsOf("modified:"):
|
|
switch token.Term[9] {
|
|
case '>':
|
|
o.UpdatedAfterUnix = toUnix(token.Term[10:])
|
|
case '<':
|
|
o.UpdatedBeforeUnix = toUnix(token.Term[10:])
|
|
default:
|
|
t := toUnix(token.Term[9:])
|
|
o.UpdatedAfterUnix = t
|
|
o.UpdatedBeforeUnix = t
|
|
}
|
|
|
|
// for user filter's
|
|
// append the names and roles
|
|
case token.IsOf("author:"):
|
|
userNames = append(userNames, token.Term[7:])
|
|
userFilter = append(userFilter, userFilterAuthor)
|
|
case token.IsOf("assignee:"):
|
|
userNames = append(userNames, token.Term[9:])
|
|
userFilter = append(userFilter, userFilterAssign)
|
|
case token.IsOf("review:"):
|
|
userNames = append(userNames, token.Term[7:])
|
|
userFilter = append(userFilter, userFilterReview)
|
|
case token.IsOf("mentions:"):
|
|
userNames = append(userNames, token.Term[9:])
|
|
userFilter = append(userFilter, userFilterMention)
|
|
|
|
default:
|
|
tokens = append(tokens, token)
|
|
}
|
|
}
|
|
if err != nil && err != io.EOF {
|
|
return err
|
|
}
|
|
|
|
o.Tokens = tokens
|
|
|
|
ids, err := user.GetUserIDsByNames(ctx, userNames, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for i, id := range ids {
|
|
// Skip all invalid IDs.
|
|
// Hopefully this won't be too astonishing for the user.
|
|
if id <= 0 {
|
|
continue
|
|
}
|
|
val := optional.Some(id)
|
|
switch userFilter[i] {
|
|
case userFilterAuthor:
|
|
o.PosterID = val
|
|
case userFilterAssign:
|
|
o.AssigneeID = val
|
|
case userFilterReview:
|
|
o.ReviewedID = val
|
|
case userFilterMention:
|
|
o.MentionID = val
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func toUnix(value string) optional.Option[int64] {
|
|
time, err := time.Parse(time.DateOnly, value)
|
|
if err != nil {
|
|
log.Warn("Failed to parse date '%v'", err)
|
|
return optional.None[int64]()
|
|
}
|
|
|
|
return optional.Some(time.Unix())
|
|
}
|
|
|
|
func parseSortBy(sortBy string) SortBy {
|
|
switch sortBy {
|
|
case "created:asc":
|
|
return SortByCreatedAsc
|
|
case "created:desc":
|
|
return SortByCreatedDesc
|
|
case "comments:asc":
|
|
return SortByCommentsAsc
|
|
case "comments:desc":
|
|
return SortByCommentsDesc
|
|
case "updated:asc":
|
|
return SortByUpdatedAsc
|
|
case "updated:desc":
|
|
return SortByUpdatedDesc
|
|
case "deadline:asc":
|
|
return SortByDeadlineAsc
|
|
case "deadline:desc":
|
|
return SortByDeadlineDesc
|
|
default:
|
|
return SortByScore
|
|
}
|
|
}
|