diff --git a/cmd/oo/search.go b/cmd/oo/search.go index 433aac9..481800b 100644 --- a/cmd/oo/search.go +++ b/cmd/oo/search.go @@ -18,14 +18,15 @@ func init() { // content search needs Elasticsearch (see docs/elasticsearch.md). func searchCmd() *cobra.Command { var ( - content bool - folder string - limit int - backend string - asJSON bool + content bool + folder string + limit int + backend string + asJSON bool + substring bool ) cmd := &cobra.Command{ - Use: "search QUERY", + Use: "search QUERY...", Short: "Full-text search over documents by name, optionally by content (Elasticsearch)", Long: "Search the OnlyOffice Documents index.\n\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" + "Requires ONLYOFFICE_ES_URL (and optionally ONLYOFFICE_ES_INDEX,\n" + "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 { if asJSON { outputFormat = "json" @@ -59,10 +60,11 @@ func searchCmd() *cobra.Command { return err } hits, err := searcher.Search(cmd.Context(), onlyoffice.SearchQuery{ - Text: args[0], + Text: strings.Join(args, " "), InContent: content, FolderID: folder, Limit: limit, + Substring: substring, }) if err != nil { return err @@ -86,7 +88,8 @@ func searchCmd() *cobra.Command { }, } 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().StringVar(&backend, "backend", "oo", "index to query: oo (OnlyOffice) | own (oo index)") cmd.Flags().BoolVar(&asJSON, "json", false, "shorthand for --output json") diff --git a/file_core.go b/file_core.go index 3461be2..a30f79b 100644 --- a/file_core.go +++ b/file_core.go @@ -80,13 +80,16 @@ type FileStore interface { } // 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 { Text string InContent bool FolderID string Extensions []string Limit int + Substring bool } // SearchHit is one Searcher result: the matching entry plus backend-specific diff --git a/file_es.go b/file_es.go index 5c286c3..e0761e6 100644 --- a/file_es.go +++ b/file_es.go @@ -28,7 +28,7 @@ import ( const ( defaultESIndex = "files_file" defaultESLimit = 20 - maxESLimit = 200 + maxESLimit = 1000 maxESResponseSize = 8 << 20 ) @@ -119,14 +119,30 @@ func esSearchRequest(q SearchQuery, tenant string) esRequest { if q.InContent { 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 if t := strings.TrimSpace(tenant); t != "" { filter = append(filter, esClause{Term: map[string]any{"tenantId": numericOrString(t)}}) } 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) { filter = append(filter, esClause{Wildcard: map[string]any{"title": "*." + ext}}) @@ -190,6 +206,22 @@ type esClause struct { Term map[string]any `json:"term,omitempty"` Terms map[string]any `json:"terms,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 { diff --git a/file_es_test.go b/file_es_test.go index ee60ef1..ae7bc7f 100644 --- a/file_es_test.go +++ b/file_es_test.go @@ -55,7 +55,7 @@ func TestESSearchRequestFiltersAndLimit(t *testing.T) { Text: "Storchen", FolderID: "649", Extensions: []string{".PDF", "pdf", "docx"}, - Limit: 999, + Limit: 5000, }, "42") if got.Size != maxESLimit { t.Errorf("size = %d, want cap %d", got.Size, maxESLimit) @@ -65,10 +65,10 @@ func TestESSearchRequestFiltersAndLimit(t *testing.T) { switch { case f.Term != nil && f.Term["tenantId"] != nil: tenant++ - case f.Term != nil && f.Term["folders.folderId"] != nil: + case f.Nested != nil: folder++ - if f.Term["folders.folderId"] != "649" { - t.Errorf("folder filter = %+v", f.Term) + if f.Nested.Path != "folders" || f.Nested.Query.Term["folders.folderId"] != "649" { + t.Errorf("folder filter = %+v, want nested folders term 649", f.Nested) } case f.Wildcard != nil: 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) { s, err := NewESSearcher(ESConfig{URL: "http://localhost:9200"}) if err != nil {