From c99922cf318401b831453a08fac06ae33eaf5d87 Mon Sep 17 00:00:00 2001 From: sofiane <131229158+sohocine@users.noreply.github.com> Date: Wed, 13 May 2026 04:48:07 +0000 Subject: [PATCH 1/7] Add all-tags item filter option Adds an optional match-all mode for item tag filters so users can require every selected tag group while preserving the existing match-any behavior by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: sofiane <131229158+sohocine@users.noreply.github.com> --- backend/internal/data/repo/repo_entities.go | 56 +++++++--- .../internal/data/repo/repo_entities_test.go | 104 ++++++++++++++++++ frontend/lib/api/classes/items.ts | 3 +- frontend/locales/en.json | 1 + frontend/pages/items.vue | 14 +++ 5 files changed, 163 insertions(+), 15 deletions(-) diff --git a/backend/internal/data/repo/repo_entities.go b/backend/internal/data/repo/repo_entities.go index b9f88973c..920e057d8 100644 --- a/backend/internal/data/repo/repo_entities.go +++ b/backend/internal/data/repo/repo_entities.go @@ -60,6 +60,7 @@ type ( ParentIDs []uuid.UUID `json:"parentIds"` TagIDs []uuid.UUID `json:"tagIds"` NegateTags bool `json:"negateTags"` + MatchAllTags bool `json:"matchAllTags"` OnlyWithoutPhoto bool `json:"onlyWithoutPhoto"` OnlyWithPhoto bool `json:"onlyWithPhoto"` ParentItemIDs []uuid.UUID `json:"parentItemIds"` @@ -605,25 +606,52 @@ func (r *EntityRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q En tagRepo := &TagRepository{r.db, r.bus} ctxDescendants, descSpan := entityTracer().Start(ctx, "repo.EntityRepository.QueryByGroup.tagDescendants", trace.WithAttributes(attribute.Int("query.tag_ids.count", len(q.TagIDs)))) - descendants, err := tagRepo.GetDescendantTagIDs(ctxDescendants, q.TagIDs) - if err != nil { - recordSpanError(descSpan, err) - log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tags") - descendants = q.TagIDs - } else if len(descendants) == 0 { - descendants = q.TagIDs + descendantGroups := make([][]uuid.UUID, 0, len(q.TagIDs)) + descendantCount := 0 + if q.MatchAllTags && !q.NegateTags { + for _, tagID := range q.TagIDs { + descendants, err := tagRepo.GetDescendantTagIDs(ctxDescendants, []uuid.UUID{tagID}) + if err != nil { + recordSpanError(descSpan, err) + log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tag") + descendants = []uuid.UUID{tagID} + } else if len(descendants) == 0 { + descendants = []uuid.UUID{tagID} + } + descendantGroups = append(descendantGroups, descendants) + descendantCount += len(descendants) + } + } else { + descendants, err := tagRepo.GetDescendantTagIDs(ctxDescendants, q.TagIDs) + if err != nil { + recordSpanError(descSpan, err) + log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tags") + descendants = q.TagIDs + } else if len(descendants) == 0 { + descendants = q.TagIDs + } + descendantGroups = append(descendantGroups, descendants) + descendantCount = len(descendants) } - descSpan.SetAttributes(attribute.Int("query.tag_descendants.count", len(descendants))) + descSpan.SetAttributes(attribute.Int("query.tag_descendants.count", descendantCount)) descSpan.End() - var tagPredicates []predicate.Entity if !q.NegateTags { - tagPredicates = lo.Map(descendants, func(l uuid.UUID, _ int) predicate.Entity { - return entity.HasTagWith(tag.ID(l)) - }) - andPredicates = append(andPredicates, entity.Or(tagPredicates...)) + if q.MatchAllTags { + for _, descendants := range descendantGroups { + tagPredicates := lo.Map(descendants, func(l uuid.UUID, _ int) predicate.Entity { + return entity.HasTagWith(tag.ID(l)) + }) + andPredicates = append(andPredicates, entity.Or(tagPredicates...)) + } + } else { + tagPredicates := lo.Map(descendantGroups[0], func(l uuid.UUID, _ int) predicate.Entity { + return entity.HasTagWith(tag.ID(l)) + }) + andPredicates = append(andPredicates, entity.Or(tagPredicates...)) + } } else { - tagPredicates = lo.Map(descendants, func(l uuid.UUID, _ int) predicate.Entity { + tagPredicates := lo.Map(descendantGroups[0], func(l uuid.UUID, _ int) predicate.Entity { return entity.Not(entity.HasTagWith(tag.ID(l))) }) andPredicates = append(andPredicates, entity.And(tagPredicates...)) diff --git a/backend/internal/data/repo/repo_entities_test.go b/backend/internal/data/repo/repo_entities_test.go index 5604acdcf..d275144d1 100644 --- a/backend/internal/data/repo/repo_entities_test.go +++ b/backend/internal/data/repo/repo_entities_test.go @@ -335,6 +335,110 @@ func TestEntityRepository_Update_Tags(t *testing.T) { } } +func TestEntityRepository_QueryByGroup_MatchAllTags(t *testing.T) { + containerET := useContainerEntityType(t) + itemET := useItemEntityType(t) + tags := useTags(t, 2) + childTag1, err := tRepos.Tags.Create(context.Background(), tGroup.ID, TagCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: tags[0].ID, + }) + require.NoError(t, err) + childTag2, err := tRepos.Tags.Create(context.Background(), tGroup.ID, TagCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: tags[1].ID, + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = tRepos.Tags.delete(context.Background(), childTag2.ID) + _ = tRepos.Tags.delete(context.Background(), childTag1.ID) + }) + + container, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + EntityTypeID: containerET.ID, + }) + require.NoError(t, err) + + wideMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: container.ID, + EntityTypeID: itemET.ID, + TagIDs: []uuid.UUID{tags[0].ID, tags[1].ID}, + }) + require.NoError(t, err) + + narrowMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: container.ID, + EntityTypeID: itemET.ID, + TagIDs: []uuid.UUID{tags[0].ID}, + }) + require.NoError(t, err) + + descendantWideMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: container.ID, + EntityTypeID: itemET.ID, + TagIDs: []uuid.UUID{childTag1.ID, childTag2.ID}, + }) + require.NoError(t, err) + + descendantNarrowMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: container.ID, + EntityTypeID: itemET.ID, + TagIDs: []uuid.UUID{childTag1.ID}, + }) + require.NoError(t, err) + + t.Cleanup(func() { + _ = tRepos.Entities.Delete(context.Background(), descendantNarrowMatch.ID) + _ = tRepos.Entities.Delete(context.Background(), descendantWideMatch.ID) + _ = tRepos.Entities.Delete(context.Background(), narrowMatch.ID) + _ = tRepos.Entities.Delete(context.Background(), wideMatch.ID) + _ = tRepos.Entities.Delete(context.Background(), container.ID) + }) + + query := EntityQuery{ + Page: -1, + PageSize: -1, + TagIDs: []uuid.UUID{tags[0].ID, tags[1].ID}, + } + + results, err := tRepos.Entities.QueryByGroup(context.Background(), tGroup.ID, query) + require.NoError(t, err) + + ids := make([]uuid.UUID, 0, len(results.Items)) + for _, item := range results.Items { + ids = append(ids, item.ID) + } + assert.Contains(t, ids, wideMatch.ID) + assert.Contains(t, ids, narrowMatch.ID) + assert.Contains(t, ids, descendantWideMatch.ID) + assert.Contains(t, ids, descendantNarrowMatch.ID) + + query.MatchAllTags = true + results, err = tRepos.Entities.QueryByGroup(context.Background(), tGroup.ID, query) + require.NoError(t, err) + + ids = ids[:0] + for _, item := range results.Items { + ids = append(ids, item.ID) + } + assert.Contains(t, ids, wideMatch.ID) + assert.Contains(t, ids, descendantWideMatch.ID) + assert.NotContains(t, ids, narrowMatch.ID) + assert.NotContains(t, ids, descendantNarrowMatch.ID) +} + func TestEntityRepository_Update(t *testing.T) { entities := useEntities(t, 3) diff --git a/frontend/lib/api/classes/items.ts b/frontend/lib/api/classes/items.ts index 64fa3f308..1838bf22a 100644 --- a/frontend/lib/api/classes/items.ts +++ b/frontend/lib/api/classes/items.ts @@ -25,6 +25,7 @@ export type ItemsQuery = { parentIds?: string[]; tags?: string[]; negateTags?: boolean; + matchAllTags?: boolean; onlyWithoutPhoto?: boolean; onlyWithPhoto?: boolean; q?: string; @@ -203,7 +204,7 @@ export class ItemsApi extends BaseAPI { return { ...resp, data: resp.data?.items ?? [], - } as { data: EntitySummary[]; error: any; status: number }; + } as { data: EntitySummary[]; error: unknown; status: number }; } getTree(tq: TreeQuery = { withItems: false }) { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b04f54196..73dcee437 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -546,6 +546,7 @@ "model_number": "Model Number", "name": "Name", "negate_tags": "Negate Selected Tags", + "match_all_tags": "Match All Selected Tags", "next_page": "Next Page", "no_attachments": "No attachments found", "no_results": "No Items Found", diff --git a/frontend/pages/items.vue b/frontend/pages/items.vue index 99762bb24..31463177c 100644 --- a/frontend/pages/items.vue +++ b/frontend/pages/items.vue @@ -85,6 +85,7 @@ const includeArchived = useOptionalRouteQuery("archived", false); const fieldSelector = useOptionalRouteQuery("fieldSelector", false); const negateTags = useOptionalRouteQuery("negateTags", false); + const matchAllTags = useOptionalRouteQuery("matchAllTags", false); const onlyWithoutPhoto = useOptionalRouteQuery("onlyWithoutPhoto", false); const onlyWithPhoto = useOptionalRouteQuery("onlyWithPhoto", false); const orderBy = useOptionalRouteQuery("orderBy", "name"); @@ -207,6 +208,12 @@ } }); + watch(matchAllTags, (newV, oldV) => { + if (newV !== oldV) { + search(); + } + }); + watch(onlyWithoutPhoto, (newV, oldV) => { if (newV && onlyWithPhoto.value) { // this triggers the watch on onlyWithPhoto @@ -275,6 +282,7 @@ archived: includeArchived.value, fieldSelector: fieldSelector.value, negateTags: negateTags.value, + matchAllTags: matchAllTags.value, onlyWithoutPhoto: onlyWithoutPhoto.value, onlyWithPhoto: onlyWithPhoto.value, orderBy: orderBy.value, @@ -312,6 +320,7 @@ parentIds: locIDs.value, tags: tagIDs.value, negateTags: negateTags.value, + matchAllTags: matchAllTags.value, onlyWithoutPhoto: onlyWithoutPhoto.value, onlyWithPhoto: onlyWithPhoto.value, includeArchived: includeArchived.value, @@ -426,6 +435,11 @@
{{ $t("items.negate_tags") }} +