diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index 8adcdda09e..013dcc0663 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -135,7 +135,7 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques Deleted: getFieldValue[bool](hit.Fields, "Deleted"), Tags: getFieldSliceValue[string](hit.Fields, "Tags"), Favorites: getFieldSliceValue[string](hit.Fields, "Favorites"), - Highlights: getFragmentValue(hit.Fragments, "Content", 0), + Highlights: buildHighlights(hit.Fragments), Audio: getAudioValue[searchMessage.Audio](hit.Fields), Image: getImageValue[searchMessage.Image](hit.Fields), Location: getLocationValue[searchMessage.GeoCoordinates](hit.Fields), diff --git a/services/search/pkg/bleve/bleve.go b/services/search/pkg/bleve/bleve.go index e478c2c148..393db257f2 100644 --- a/services/search/pkg/bleve/bleve.go +++ b/services/search/pkg/bleve/bleve.go @@ -62,6 +62,27 @@ func getFieldSliceValue[T any](m map[string]any, key string) (out []T) { return } +// buildHighlights combines Content and Metadata highlights into a single string. +// Content highlights are shown first, followed by metadata field matches. +func buildHighlights(fragments bleveSearch.FieldFragmentMap) string { + var parts []string + + // Content highlight (traditional full-text match) + if contentFrags, ok := fragments["Content"]; ok && len(contentFrags) > 0 { + parts = append(parts, contentFrags[0]) + } + + // Metadata highlights (e.g. Metadata.oy.fileReference, Metadata.oy.subject, ...) + for field, frags := range fragments { + if strings.HasPrefix(field, "Metadata.") && len(frags) > 0 { + key := strings.TrimPrefix(field, "Metadata.") + parts = append(parts, key+": "+frags[0]) + } + } + + return strings.Join(parts, " · ") +} + func getFragmentValue(m bleveSearch.FieldFragmentMap, key string, idx int) string { val, ok := m[key] if !ok { @@ -178,6 +199,19 @@ func getFieldName(structField reflect.StructField) string { } func matchToResource(match *bleveSearch.DocumentMatch) *search.Resource { + // Extract Metadata.* fields from the flat bleve field map + metadata := make(map[string]string) + for k, v := range match.Fields { + if strings.HasPrefix(k, "Metadata.") { + if s, ok := v.(string); ok { + metadata[strings.TrimPrefix(k, "Metadata.")] = s + } + } + } + if len(metadata) == 0 { + metadata = nil + } + return &search.Resource{ ID: getFieldValue[string](match.Fields, "ID"), RootID: getFieldValue[string](match.Fields, "RootID"), @@ -194,6 +228,7 @@ func matchToResource(match *bleveSearch.DocumentMatch) *search.Resource { Content: getFieldValue[string](match.Fields, "Content"), Tags: getFieldSliceValue[string](match.Fields, "Tags"), Favorites: getFieldSliceValue[string](match.Fields, "Favorites"), + Metadata: metadata, Audio: getAudioValue[libregraph.Audio](match.Fields), Image: getImageValue[libregraph.Image](match.Fields), Location: getLocationValue[libregraph.GeoCoordinates](match.Fields), diff --git a/services/search/pkg/bleve/index.go b/services/search/pkg/bleve/index.go index f11bd61402..c4b093a2ae 100644 --- a/services/search/pkg/bleve/index.go +++ b/services/search/pkg/bleve/index.go @@ -49,11 +49,17 @@ func NewMapping() (mapping.IndexMapping, error) { fulltextFieldMapping.Analyzer = "fulltext" fulltextFieldMapping.IncludeInAll = false + // Metadata sub-document: each key is a searchable text field + metadataMapping := bleve.NewDocumentMapping() + metadataMapping.Dynamic = true + metadataMapping.DefaultAnalyzer = "lowercaseKeyword" + docMapping := bleve.NewDocumentMapping() docMapping.AddFieldMappingsAt("Name", nameMapping) docMapping.AddFieldMappingsAt("Tags", lowercaseMapping) docMapping.AddFieldMappingsAt("Favorites", lowercaseMapping) docMapping.AddFieldMappingsAt("Content", fulltextFieldMapping) + docMapping.AddSubDocumentMapping("Metadata", metadataMapping) indexMapping := bleve.NewIndexMapping() indexMapping.DefaultAnalyzer = keyword.Name diff --git a/services/search/pkg/content/basic.go b/services/search/pkg/content/basic.go index 1499258c98..6133f8a6a9 100644 --- a/services/search/pkg/content/basic.go +++ b/services/search/pkg/content/basic.go @@ -33,6 +33,16 @@ func (b Basic) Extract(_ context.Context, ri *storageProvider.ResourceInfo) (Doc if t, ok := m["tags"]; ok { doc.Tags = tags.New(t).AsSlice() } + // Index all arbitrary metadata (user.oc.md.* xattrs) + md := make(map[string]string) + for k, v := range m { + if k != "tags" && v != "" { + md[k] = v + } + } + if len(md) > 0 { + doc.Metadata = md + } } if m := ri.Opaque.GetMap(); m != nil && m["favorites"] != nil { diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index e185351678..b8388f8eb5 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -22,6 +22,7 @@ type Document struct { MimeType string Tags []string Favorites []string + Metadata map[string]string `json:"metadata,omitempty"` Audio *libregraph.Audio `json:"audio,omitempty"` Image *libregraph.Image `json:"image,omitempty"` Location *libregraph.GeoCoordinates `json:"location,omitempty"` diff --git a/services/search/pkg/opensearch/internal/convert/opensearch.go b/services/search/pkg/opensearch/internal/convert/opensearch.go index c4d8212dcd..575bc45935 100644 --- a/services/search/pkg/opensearch/internal/convert/opensearch.go +++ b/services/search/pkg/opensearch/internal/convert/opensearch.go @@ -61,12 +61,17 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, Deleted: resource.Deleted, Tags: resource.Tags, Highlights: func() string { - contentHighlights, ok := hit.Highlight["Content"] - if !ok { - return "" + var parts []string + if contentHighlights, ok := hit.Highlight["Content"]; ok { + parts = append(parts, strings.Join(contentHighlights, "; ")) } - - return strings.Join(contentHighlights[:], "; ") + for field, frags := range hit.Highlight { + if strings.HasPrefix(field, "Metadata.") && len(frags) > 0 { + key := strings.TrimPrefix(field, "Metadata.") + parts = append(parts, key+": "+strings.Join(frags, "; ")) + } + } + return strings.Join(parts, " · ") }(), Audio: func() *searchMessage.Audio { if !strings.HasPrefix(resource.MimeType, "audio/") {