From 14efc29f779b62789ee716a5516958d6baa601aa Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Mon, 10 Aug 2026 08:14:53 -0300 Subject: [PATCH] Enhancement: Support search highlights Add `IncludeHighlights` search filter and `SearchItem.Highlights()`, a typed accessor for the `meta.highlights` fragments where matches are wrapped in `` tags. --- projects/search.go | 46 ++++++++++++++++++++ projects/search_test.go | 96 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/projects/search.go b/projects/search.go index 02c853d..d31a47c 100644 --- a/projects/search.go +++ b/projects/search.go @@ -26,6 +26,40 @@ var ( // https://support.teamwork.com/projects/using-teamwork/search-command-center type SearchItem twapi.Relationship +// Highlights returns the highlighted match fragments for the search item, +// mapping a document field name (e.g. taskName, body) to text fragments where +// matched terms are wrapped in tags. It returns nil when the search was +// not executed with IncludeHighlights or no highlights are available for the +// item. The fragment text is not HTML-escaped, so escape it before rendering +// as HTML. +func (s SearchItem) Highlights() map[string][]string { + fields, ok := s.Meta["highlights"].(map[string]any) + if !ok { + return nil + } + var highlights map[string][]string + for field, value := range fields { + rawFragments, ok := value.([]any) + if !ok { + continue + } + var fragments []string + for _, rawFragment := range rawFragments { + if fragment, ok := rawFragment.(string); ok { + fragments = append(fragments, fragment) + } + } + if fragments == nil { + continue + } + if highlights == nil { + highlights = make(map[string][]string) + } + highlights[field] = fragments + } + return highlights +} + // SearchRequestPath contains the path parameters for loading multiple // searches. type SearchRequestPath struct{} @@ -113,6 +147,15 @@ type SearchRequestFilters struct { // searching for items updated more than 5 years ago. The default is false. ExtendedSearch *bool + // IncludeHighlights is an optional flag to include highlighted match + // fragments for each result in the item's meta, retrievable via the + // SearchItem.Highlights method. Fragments mark matched terms with and + // tags; the surrounding text is not HTML-escaped, so escape it before + // rendering as HTML. Highlights are only available on standard searches — + // extended and other database-backed searches return no highlights. The + // default is false. + IncludeHighlights *bool + // Cursor is an optional cursor to retrieve the next set of results. Cursor string @@ -147,6 +190,9 @@ func (s SearchRequestFilters) apply(req *http.Request) { if s.ExtendedSearch != nil { query.Set("extendedSearch", strconv.FormatBool(*s.ExtendedSearch)) } + if s.IncludeHighlights != nil { + query.Set("includeHighlights", strconv.FormatBool(*s.IncludeHighlights)) + } if s.Cursor != "" { query.Set("cursor", s.Cursor) } diff --git a/projects/search_test.go b/projects/search_test.go index 70b7931..07f8728 100644 --- a/projects/search_test.go +++ b/projects/search_test.go @@ -2,6 +2,9 @@ package projects_test import ( "context" + "encoding/json" + "net/url" + "reflect" "testing" "time" @@ -34,3 +37,96 @@ func TestSearch(t *testing.T) { }) } } + +func TestSearchRequestGeneration(t *testing.T) { + includeHighlights := true + + req := projects.NewSearchRequest("example") + req.Filters.IncludeHighlights = &includeHighlights + + httpReq, err := req.HTTPRequest(context.Background(), "https://test.com") + if err != nil { + t.Fatalf("unexpected error creating HTTP request: %s", err) + } + + query, err := url.ParseQuery(httpReq.URL.RawQuery) + if err != nil { + t.Fatalf("failed to parse query string: %s", err) + } + + if query.Get("searchTerm") != "example" { + t.Errorf("expected searchTerm=example but got %q", query.Get("searchTerm")) + } + if query.Get("includeHighlights") != "true" { + t.Errorf("expected includeHighlights=true but got %q", query.Get("includeHighlights")) + } +} + +func TestSearchRequestGeneration_noIncludeHighlights(t *testing.T) { + req := projects.NewSearchRequest("example") + + httpReq, err := req.HTTPRequest(context.Background(), "https://test.com") + if err != nil { + t.Fatalf("unexpected error creating HTTP request: %s", err) + } + + query, err := url.ParseQuery(httpReq.URL.RawQuery) + if err != nil { + t.Fatalf("failed to parse query string: %s", err) + } + + if query.Has("includeHighlights") { + t.Errorf("expected includeHighlights to be unset but got %q", query.Get("includeHighlights")) + } +} + +func TestSearchItemHighlights(t *testing.T) { + tests := []struct { + name string + body string + want map[string][]string + }{{ + name: "valid highlights", + body: `{"id":15,"type":"tasks","meta":{"highlights":{` + + `"taskName":["Task 1"],` + + `"description":["something about the task","second task fragment"]}}}`, + want: map[string][]string{ + "taskName": {"Task 1"}, + "description": {"something about the task", "second task fragment"}, + }, + }, { + name: "no meta", + body: `{"id":15,"type":"tasks"}`, + }, { + name: "meta without highlights", + body: `{"id":15,"type":"tasks","meta":{"other":true}}`, + }, { + name: "highlights is not an object", + body: `{"id":15,"type":"tasks","meta":{"highlights":"broken"}}`, + }, { + name: "field is not an array", + body: `{"id":15,"type":"tasks","meta":{"highlights":{"taskName":"broken"}}}`, + }, { + name: "malformed fields are skipped, valid fields kept", + body: `{"id":15,"type":"tasks","meta":{"highlights":{` + + `"taskName":["Task 1"],` + + `"description":"broken",` + + `"body":[42]}}}`, + want: map[string][]string{ + "taskName": {"Task 1"}, + }, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var item projects.SearchItem + if err := json.Unmarshal([]byte(tt.body), &item); err != nil { + t.Fatalf("failed to decode search item: %s", err) + } + + if got := item.Highlights(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("highlights = %v, want %v", got, tt.want) + } + }) + } +}