feat(search): substring/AND terms, nested folder scope, limit 1000 (#49)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 26s
Tests / Test (Go 1.25) (pull_request) Successful in 39s
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 26s
Tests / Test (Go 1.25) (pull_request) Successful in 39s
This commit is contained in:
+12
-9
@@ -18,14 +18,15 @@ func init() {
|
|||||||
// content search needs Elasticsearch (see docs/elasticsearch.md).
|
// content search needs Elasticsearch (see docs/elasticsearch.md).
|
||||||
func searchCmd() *cobra.Command {
|
func searchCmd() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
content bool
|
content bool
|
||||||
folder string
|
folder string
|
||||||
limit int
|
limit int
|
||||||
backend string
|
backend string
|
||||||
asJSON bool
|
asJSON bool
|
||||||
|
substring bool
|
||||||
)
|
)
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "search QUERY",
|
Use: "search QUERY...",
|
||||||
Short: "Full-text search over documents by name, optionally by content (Elasticsearch)",
|
Short: "Full-text search over documents by name, optionally by content (Elasticsearch)",
|
||||||
Long: "Search the OnlyOffice Documents index.\n\n" +
|
Long: "Search the OnlyOffice Documents index.\n\n" +
|
||||||
"By default only file names are matched. With --content the query also\n" +
|
"By default only file names are matched. With --content the query also\n" +
|
||||||
@@ -36,7 +37,7 @@ func searchCmd() *cobra.Command {
|
|||||||
"PDFs and scans (see docs/elasticsearch.md).\n\n" +
|
"PDFs and scans (see docs/elasticsearch.md).\n\n" +
|
||||||
"Requires ONLYOFFICE_ES_URL (and optionally ONLYOFFICE_ES_INDEX,\n" +
|
"Requires ONLYOFFICE_ES_URL (and optionally ONLYOFFICE_ES_INDEX,\n" +
|
||||||
"ONLYOFFICE_TENANT). See docs/elasticsearch.md for the tunnel setup.",
|
"ONLYOFFICE_TENANT). See docs/elasticsearch.md for the tunnel setup.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if asJSON {
|
if asJSON {
|
||||||
outputFormat = "json"
|
outputFormat = "json"
|
||||||
@@ -59,10 +60,11 @@ func searchCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hits, err := searcher.Search(cmd.Context(), onlyoffice.SearchQuery{
|
hits, err := searcher.Search(cmd.Context(), onlyoffice.SearchQuery{
|
||||||
Text: args[0],
|
Text: strings.Join(args, " "),
|
||||||
InContent: content,
|
InContent: content,
|
||||||
FolderID: folder,
|
FolderID: folder,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
|
Substring: substring,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -86,7 +88,8 @@ func searchCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().BoolVar(&content, "content", false, "also match extracted document content")
|
cmd.Flags().BoolVar(&content, "content", false, "also match extracted document content")
|
||||||
cmd.Flags().StringVar(&folder, "folder", "", "limit to a Documents folder id")
|
cmd.Flags().BoolVar(&substring, "substring", false, "case-insensitive *term* title match; multiple QUERY args are ANDed")
|
||||||
|
cmd.Flags().StringVar(&folder, "folder", "", "limit to a Documents folder id (matches the folder subtree)")
|
||||||
cmd.Flags().IntVar(&limit, "limit", 20, "maximum number of results")
|
cmd.Flags().IntVar(&limit, "limit", 20, "maximum number of results")
|
||||||
cmd.Flags().StringVar(&backend, "backend", "oo", "index to query: oo (OnlyOffice) | own (oo index)")
|
cmd.Flags().StringVar(&backend, "backend", "oo", "index to query: oo (OnlyOffice) | own (oo index)")
|
||||||
cmd.Flags().BoolVar(&asJSON, "json", false, "shorthand for --output json")
|
cmd.Flags().BoolVar(&asJSON, "json", false, "shorthand for --output json")
|
||||||
|
|||||||
+4
-1
@@ -80,13 +80,16 @@ type FileStore interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SearchQuery narrows a Searcher request. InContent asks the backend to match
|
// SearchQuery narrows a Searcher request. InContent asks the backend to match
|
||||||
// document bodies, not just titles.
|
// document bodies, not just titles. Substring switches title matching from the
|
||||||
|
// analyzer's whole-token match to a case-insensitive "*term*" wildcard and ANDs
|
||||||
|
// every whitespace-separated term (e.g. "rechnung 2025").
|
||||||
type SearchQuery struct {
|
type SearchQuery struct {
|
||||||
Text string
|
Text string
|
||||||
InContent bool
|
InContent bool
|
||||||
FolderID string
|
FolderID string
|
||||||
Extensions []string
|
Extensions []string
|
||||||
Limit int
|
Limit int
|
||||||
|
Substring bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchHit is one Searcher result: the matching entry plus backend-specific
|
// SearchHit is one Searcher result: the matching entry plus backend-specific
|
||||||
|
|||||||
+35
-3
@@ -28,7 +28,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultESIndex = "files_file"
|
defaultESIndex = "files_file"
|
||||||
defaultESLimit = 20
|
defaultESLimit = 20
|
||||||
maxESLimit = 200
|
maxESLimit = 1000
|
||||||
maxESResponseSize = 8 << 20
|
maxESResponseSize = 8 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,14 +119,30 @@ func esSearchRequest(q SearchQuery, tenant string) esRequest {
|
|||||||
if q.InContent {
|
if q.InContent {
|
||||||
fields = append(fields, "document.attachment.content")
|
fields = append(fields, "document.attachment.content")
|
||||||
}
|
}
|
||||||
must := []esClause{{MultiMatch: &esMultiMatch{Query: q.Text, Fields: fields}}}
|
var must []esClause
|
||||||
|
if q.Substring {
|
||||||
|
for _, term := range strings.Fields(strings.ToLower(q.Text)) {
|
||||||
|
if term = escapeWildcard(term); term != "" {
|
||||||
|
must = append(must, esClause{Wildcard: map[string]any{"title": "*" + term + "*"}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(must) == 0 {
|
||||||
|
must = []esClause{{MultiMatch: &esMultiMatch{Query: q.Text, Fields: fields}}}
|
||||||
|
}
|
||||||
|
|
||||||
var filter []esClause
|
var filter []esClause
|
||||||
if t := strings.TrimSpace(tenant); t != "" {
|
if t := strings.TrimSpace(tenant); t != "" {
|
||||||
filter = append(filter, esClause{Term: map[string]any{"tenantId": numericOrString(t)}})
|
filter = append(filter, esClause{Term: map[string]any{"tenantId": numericOrString(t)}})
|
||||||
}
|
}
|
||||||
if f := strings.TrimSpace(q.FolderID); f != "" {
|
if f := strings.TrimSpace(q.FolderID); f != "" {
|
||||||
filter = append(filter, esClause{Term: map[string]any{"folders.folderId": f}})
|
// folders is an ES nested field; a plain term on folders.folderId would
|
||||||
|
// not match. The stored Folders list holds every ancestor id, so
|
||||||
|
// filtering by a project root id scopes to its whole subtree.
|
||||||
|
filter = append(filter, esClause{Nested: &esNested{
|
||||||
|
Path: "folders",
|
||||||
|
Query: esNestedTerm{Term: map[string]any{"folders.folderId": f}},
|
||||||
|
}})
|
||||||
}
|
}
|
||||||
for _, ext := range normalizeExtensions(q.Extensions) {
|
for _, ext := range normalizeExtensions(q.Extensions) {
|
||||||
filter = append(filter, esClause{Wildcard: map[string]any{"title": "*." + ext}})
|
filter = append(filter, esClause{Wildcard: map[string]any{"title": "*." + ext}})
|
||||||
@@ -190,6 +206,22 @@ type esClause struct {
|
|||||||
Term map[string]any `json:"term,omitempty"`
|
Term map[string]any `json:"term,omitempty"`
|
||||||
Terms map[string]any `json:"terms,omitempty"`
|
Terms map[string]any `json:"terms,omitempty"`
|
||||||
Wildcard map[string]any `json:"wildcard,omitempty"`
|
Wildcard map[string]any `json:"wildcard,omitempty"`
|
||||||
|
Nested *esNested `json:"nested,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type esNested struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Query esNestedTerm `json:"query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type esNestedTerm struct {
|
||||||
|
Term map[string]any `json:"term,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeWildcard strips ES wildcard metacharacters from a user term so a query
|
||||||
|
// cannot inject wildcard syntax. Pure, so it is unit-tested.
|
||||||
|
func escapeWildcard(s string) string {
|
||||||
|
return strings.NewReplacer("*", "", "?", "", `\`, "").Replace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
type esMultiMatch struct {
|
type esMultiMatch struct {
|
||||||
|
|||||||
+32
-4
@@ -55,7 +55,7 @@ func TestESSearchRequestFiltersAndLimit(t *testing.T) {
|
|||||||
Text: "Storchen",
|
Text: "Storchen",
|
||||||
FolderID: "649",
|
FolderID: "649",
|
||||||
Extensions: []string{".PDF", "pdf", "docx"},
|
Extensions: []string{".PDF", "pdf", "docx"},
|
||||||
Limit: 999,
|
Limit: 5000,
|
||||||
}, "42")
|
}, "42")
|
||||||
if got.Size != maxESLimit {
|
if got.Size != maxESLimit {
|
||||||
t.Errorf("size = %d, want cap %d", got.Size, maxESLimit)
|
t.Errorf("size = %d, want cap %d", got.Size, maxESLimit)
|
||||||
@@ -65,10 +65,10 @@ func TestESSearchRequestFiltersAndLimit(t *testing.T) {
|
|||||||
switch {
|
switch {
|
||||||
case f.Term != nil && f.Term["tenantId"] != nil:
|
case f.Term != nil && f.Term["tenantId"] != nil:
|
||||||
tenant++
|
tenant++
|
||||||
case f.Term != nil && f.Term["folders.folderId"] != nil:
|
case f.Nested != nil:
|
||||||
folder++
|
folder++
|
||||||
if f.Term["folders.folderId"] != "649" {
|
if f.Nested.Path != "folders" || f.Nested.Query.Term["folders.folderId"] != "649" {
|
||||||
t.Errorf("folder filter = %+v", f.Term)
|
t.Errorf("folder filter = %+v, want nested folders term 649", f.Nested)
|
||||||
}
|
}
|
||||||
case f.Wildcard != nil:
|
case f.Wildcard != nil:
|
||||||
wildcards++
|
wildcards++
|
||||||
@@ -82,6 +82,34 @@ func TestESSearchRequestFiltersAndLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestESSearchRequestSubstringAndsTerms(t *testing.T) {
|
||||||
|
got := esSearchRequest(SearchQuery{Text: "Rechnung 2025", Substring: true, FolderID: "522"}, "")
|
||||||
|
if got.Query.Bool.Must[0].MultiMatch != nil {
|
||||||
|
t.Fatalf("substring must not use multi_match: %+v", got.Query.Bool.Must)
|
||||||
|
}
|
||||||
|
if len(got.Query.Bool.Must) != 2 {
|
||||||
|
t.Fatalf("must = %+v, want two ANDed wildcard terms", got.Query.Bool.Must)
|
||||||
|
}
|
||||||
|
want := []string{"*rechnung*", "*2025*"}
|
||||||
|
for i, m := range got.Query.Bool.Must {
|
||||||
|
if m.Wildcard == nil || m.Wildcard["title"] != want[i] {
|
||||||
|
t.Errorf("must[%d] = %+v, want title wildcard %q", i, m, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got.Query.Bool.Filter) != 1 || got.Query.Bool.Filter[0].Nested == nil {
|
||||||
|
t.Errorf("folder filter = %+v, want nested", got.Query.Bool.Filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeWildcard(t *testing.T) {
|
||||||
|
cases := map[string]string{"*rechnung*": "rechnung", "a?b\\c": "abc", "plain": "plain"}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := escapeWildcard(in); got != want {
|
||||||
|
t.Errorf("escapeWildcard(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestESSearchRequestRejectsEmptyTextAtSearch(t *testing.T) {
|
func TestESSearchRequestRejectsEmptyTextAtSearch(t *testing.T) {
|
||||||
s, err := NewESSearcher(ESConfig{URL: "http://localhost:9200"})
|
s, err := NewESSearcher(ESConfig{URL: "http://localhost:9200"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user