From 9135096662b5c295b71b0f6fa02e8b7957e7ed9d Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Mon, 02 Mar 2026 03:26:34 +0000 Subject: [PATCH] appview/searchquery: add dynamic tag extraction and shared resolution helpers Add KnownTags map and GetDynamicTags/GetNegatedDynamicTags methods to extract label-value search filters from parsed queries. Any tag:value pair whose key is not a known system tag (state, author, label) is treated as a dynamic label filter. Add resolve.go with shared helpers: IdentResolver type, ResolveAuthor, ExtractTextFilters, and ResolveDIDLabelValues. These keep resolution logic in the searchquery package without coupling it to idresolver. Signed-off-by: Thomas Karpiniec --- appview/searchquery/resolve.go | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/searchquery/searchquery.go | 33 +++++++++++++++++++++++++++++++++ appview/searchquery/searchquery_test.go | 23 +++++++++++++++++++++++ 3 file(s) changed, 155 insertion(s)(+), 0 deletion(s)(-) diff --git a/appview/searchquery/resolve.go b/appview/searchquery/resolve.go new file mode 100644 --- /dev/null +++ b/appview/searchquery/resolve.go @@ -0,0 +1,99 @@ +package searchquery + +import ( + "context" + "log/slog" + "strings" +) + +// IdentResolver converts a handle/identifier string to a DID string. +type IdentResolver func(ctx context.Context, ident string) (string, error) + +// ResolveAuthor extracts the "author" tag from the query and resolves +// both the positive and negated values to DIDs using the provided resolver. +// Returns empty string / nil on missing tags or resolution failure. +func ResolveAuthor(ctx context.Context, q *Query, resolve IdentResolver, log *slog.Logger) (authorDid string, negatedAuthorDids []string) { + if authorHandle := q.Get("author"); authorHandle != nil { + did, err := resolve(ctx, *authorHandle) + if err != nil { + log.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) + } else { + authorDid = did + } + } + + for _, handle := range q.GetAllNegated("author") { + did, err := resolve(ctx, handle) + if err != nil { + log.Debug("failed to resolve negated author handle", "handle", handle, "err", err) + continue + } + negatedAuthorDids = append(negatedAuthorDids, did) + } + + return authorDid, negatedAuthorDids +} + +// TextFilters holds the keyword and phrase filters extracted from a query. +type TextFilters struct { + Keywords []string + NegatedKeywords []string + Phrases []string + NegatedPhrases []string +} + +// ExtractTextFilters extracts keyword and quoted-phrase items from the query, +// separated into positive and negated slices. +func ExtractTextFilters(q *Query) TextFilters { + var tf TextFilters + for _, item := range q.Items() { + switch item.Kind { + case KindKeyword: + if item.Negated { + tf.NegatedKeywords = append(tf.NegatedKeywords, item.Value) + } else { + tf.Keywords = append(tf.Keywords, item.Value) + } + case KindQuoted: + if item.Negated { + tf.NegatedPhrases = append(tf.NegatedPhrases, item.Value) + } else { + tf.Phrases = append(tf.Phrases, item.Value) + } + } + } + return tf +} + +// ResolveDIDLabelValues resolves handle values to DIDs for dynamic label +// tags whose name appears in didLabels. Tags not in didLabels are returned +// unchanged. On resolution failure the original value is kept. +func ResolveDIDLabelValues( + ctx context.Context, + tags []string, + didLabels map[string]bool, + resolve IdentResolver, + log *slog.Logger, +) []string { + resolved := make([]string, 0, len(tags)) + for _, tag := range tags { + idx := strings.Index(tag, ":") + if idx < 0 { + resolved = append(resolved, tag) + continue + } + name, val := tag[:idx], tag[idx+1:] + if didLabels[name] { + did, err := resolve(ctx, val) + if err != nil { + log.Debug("failed to resolve DID label value", "label", name, "value", val, "err", err) + resolved = append(resolved, tag) + continue + } + resolved = append(resolved, name+":"+did) + } else { + resolved = append(resolved, tag) + } + } + return resolved +} diff --git a/appview/searchquery/searchquery.go b/appview/searchquery/searchquery.go --- a/appview/searchquery/searchquery.go +++ b/appview/searchquery/searchquery.go @@ -158,6 +158,39 @@ return q.Get(key) != nil } +// KnownTags is the set of tag keys with special system-defined handling. +// Any tag:value pair whose key is not in this set is treated as a dynamic +// label filter. +var KnownTags = map[string]bool{ + "state": true, + "author": true, + "label": true, +} + +// GetDynamicTags returns composite "key:value" strings for all non-negated +// tag:value items whose key is not a known system tag. +func (q *Query) GetDynamicTags() []string { + var result []string + for _, item := range q.items { + if item.Kind == KindTagValue && !item.Negated && !KnownTags[item.Key] { + result = append(result, item.Key+":"+item.Value) + } + } + return result +} + +// GetNegatedDynamicTags returns composite "key:value" strings for all negated +// tag:value items whose key is not a known system tag. +func (q *Query) GetNegatedDynamicTags() []string { + var result []string + for _, item := range q.items { + if item.Kind == KindTagValue && item.Negated && !KnownTags[item.Key] { + result = append(result, item.Key+":"+item.Value) + } + } + return result +} + func (q *Query) Set(key, value string) { raw := key + ":" + value found := false diff --git a/appview/searchquery/searchquery_test.go b/appview/searchquery/searchquery_test.go --- a/appview/searchquery/searchquery_test.go +++ b/appview/searchquery/searchquery_test.go @@ -250,3 +250,26 @@ assert.Equal(t, "-label", items[0].Key) assert.Equal(t, "bug", items[0].Value) } + +func TestDynamicTags(t *testing.T) { + q := Parse("state:open label:bug priority:high severity:critical -priority:low author:alice -severity:minor keyword") + + // Known tags are not included + dynamic := q.GetDynamicTags() + assert.Equal(t, []string{"priority:high", "severity:critical"}, dynamic) + + negated := q.GetNegatedDynamicTags() + assert.Equal(t, []string{"priority:low", "severity:minor"}, negated) + + // Known tags still work as before + assert.Equal(t, []string{"bug"}, q.GetAll("label")) + val := q.Get("state") + assert.NotNil(t, val) + assert.Equal(t, "open", *val) +} + +func TestDynamicTagsEmpty(t *testing.T) { + q := Parse("state:open label:bug author:alice keyword") + assert.Equal(t, 0, len(q.GetDynamicTags())) + assert.Equal(t, 0, len(q.GetNegatedDynamicTags())) +} -- tangled.sh