From 67b1dd8dd05f52654caaca010492ad67c03bc23a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 12 Dec 2025 08:54:57 +1100 Subject: [PATCH] Add tag stash ids filter criterion (#6403) * Add stash id filter to tag filter * Add tag stash id criterion in UI --- graphql/schema/types/filters.graphql | 3 + pkg/models/tag.go | 2 + pkg/sqlite/setup_test.go | 13 ++++ pkg/sqlite/tag_filter.go | 8 ++ pkg/sqlite/tag_test.go | 103 +++++++++++++++++++++++++ ui/v2.5/src/models/list-filter/tags.ts | 2 + 6 files changed, 131 insertions(+) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 4eb91aa77..bb312e31d 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -606,6 +606,9 @@ input TagFilterType { "Filter by autotag ignore value" ignore_auto_tag: Boolean + "Filter by StashID" + stash_id_endpoint: StashIDCriterionInput + "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related images that meet this criteria" diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 1971a8bb6..29b7e9be3 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -40,6 +40,8 @@ type TagFilterType struct { ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by StashID Endpoint + StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related images that meet this criteria diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 704dde8a2..2e95012b5 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1688,6 +1688,13 @@ func getTagChildCount(id int) int { return 0 } +func tagStashID(i int) models.StashID { + return models.StashID{ + StashID: getTagStringValue(i, "stashid"), + Endpoint: getTagStringValue(i, "endpoint"), + } +} + // createTags creates n tags with plain Name and o tags with camel cased NaMe included func createTags(ctx context.Context, tqb models.TagReaderWriter, n int, o int) error { const namePlain = "Name" @@ -1709,6 +1716,12 @@ func createTags(ctx context.Context, tqb models.TagReaderWriter, n int, o int) e IgnoreAutoTag: getIgnoreAutoTag(i), } + if (index+1)%5 != 0 { + tag.StashIDs = models.NewRelatedStashIDs([]models.StashID{ + tagStashID(i), + }) + } + err := tqb.Create(ctx, &tag) if err != nil { diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 27afd5858..27ccf3c09 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -84,6 +84,14 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { tagHierarchyHandler.ChildrenCriterionHandler(tagFilter.Children), tagHierarchyHandler.ParentCountCriterionHandler(tagFilter.ParentCount), tagHierarchyHandler.ChildCountCriterionHandler(tagFilter.ChildCount), + + &stashIDCriterionHandler{ + c: tagFilter.StashIDEndpoint, + stashIDRepository: &tagRepository.stashIDs, + stashIDTableAs: "tag_stash_ids", + parentIDCol: "tags.id", + }, + ×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil}, ×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil}, diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 7d7d1bb09..18fe486bc 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -343,6 +343,109 @@ func queryTags(ctx context.Context, t *testing.T, qb models.TagReader, tagFilter return tags } +func tagsToIDs(i []*models.Tag) []int { + ret := make([]int, len(i)) + for i, v := range i { + ret[i] = v.ID + } + + return ret +} + +func TestTagQuery(t *testing.T) { + var ( + endpoint = tagStashID(tagIdxWithPerformer).Endpoint + stashID = tagStashID(tagIdxWithPerformer).StashID + ) + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.TagFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "stash id with endpoint", + nil, + &models.TagFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + StashID: &stashID, + Modifier: models.CriterionModifierEquals, + }, + }, + []int{tagIdxWithPerformer}, + nil, + false, + }, + { + "exclude stash id with endpoint", + nil, + &models.TagFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + StashID: &stashID, + Modifier: models.CriterionModifierNotEquals, + }, + }, + nil, + []int{tagIdxWithPerformer}, + false, + }, + { + "null stash id with endpoint", + nil, + &models.TagFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierIsNull, + }, + }, + nil, + []int{tagIdxWithPerformer}, + false, + }, + { + "not null stash id with endpoint", + nil, + &models.TagFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{tagIdxWithPerformer}, + nil, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + tags, _, err := db.Tag.Query(ctx, tt.filter, tt.findFilter) + if (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + + ids := tagsToIDs(tags) + include := indexesToIDs(tagIDs, tt.includeIdxs) + exclude := indexesToIDs(tagIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + func TestTagQueryIsMissingImage(t *testing.T) { withTxn(func(ctx context.Context) error { qb := db.Tag diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index c664f218e..e2d4fbed4 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -14,6 +14,7 @@ import { ParentTagsCriterionOption, } from "./criteria/tags"; import { FavoriteTagCriterionOption } from "./criteria/favorite"; +import { StashIDCriterionOption } from "./criteria/stash-ids"; const defaultSortBy = "name"; const sortByOptions = ["name", "random", "scenes_duration"] @@ -58,6 +59,7 @@ const criterionOptions = [ createStringCriterionOption("aliases"), createStringCriterionOption("description"), createBooleanCriterionOption("ignore_auto_tag"), + StashIDCriterionOption, createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"),