Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions projects/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em> 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{}
Expand Down Expand Up @@ -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 <em> and
// </em> 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

Expand Down Expand Up @@ -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)
}
Expand Down
96 changes: 96 additions & 0 deletions projects/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package projects_test

import (
"context"
"encoding/json"
"net/url"
"reflect"
"testing"
"time"

Expand Down Expand Up @@ -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":["<em>Task</em> 1"],` +
`"description":["something about the <em>task</em>","second <em>task</em> fragment"]}}}`,
want: map[string][]string{
"taskName": {"<em>Task</em> 1"},
"description": {"something about the <em>task</em>", "second <em>task</em> 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":["<em>Task</em> 1"],` +
`"description":"broken",` +
`"body":[42]}}}`,
want: map[string][]string{
"taskName": {"<em>Task</em> 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)
}
})
}
}