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") }}
+