diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 4eb91aa77..13fc2ef7f 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -81,6 +81,18 @@ input PHashDuplicationCriterionInput { distance: Int } +input StashIDDuplicationCriterionInput { + duplicated: Boolean +} + +input TitleDuplicationCriterionInput { + duplicated: Boolean +} + +input URLDuplicationCriterionInput { + duplicated: Boolean +} + input StashIDCriterionInput { """ If present, this value is treated as a predicate. @@ -250,6 +262,12 @@ input SceneFilterType { o_counter: IntCriterionInput "Filter Scenes that have an exact phash match available" duplicated: PHashDuplicationCriterionInput + "Filter Scenes that have the same stash_id" + duplicated_stash_id: StashIDDuplicationCriterionInput + "Filter Scenes that have the same title" + duplicated_title: TitleDuplicationCriterionInput + "Filter Scenes that have the same URL" + duplicated_url: URLDuplicationCriterionInput "Filter by resolution" resolution: ResolutionCriterionInput "Filter by orientation" diff --git a/pkg/models/scene.go b/pkg/models/scene.go index f0a863bf7..364ea2b75 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -8,6 +8,18 @@ type PHashDuplicationCriterionInput struct { Distance *int `json:"distance"` } +type StashIDDuplicationCriterionInput struct { + Duplicated *bool `json:"duplicated"` +} + +type TitleDuplicationCriterionInput struct { + Duplicated *bool `json:"duplicated"` +} + +type URLDuplicationCriterionInput struct { + Duplicated *bool `json:"duplicated"` +} + type SceneFilterType struct { OperatorFilter[SceneFilterType] ID *IntCriterionInput `json:"id"` @@ -35,6 +47,12 @@ type SceneFilterType struct { OCounter *IntCriterionInput `json:"o_counter"` // Filter Scenes that have an exact phash match available Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` + // Filter Scenes that have the same stash_id + DuplicatedStashID *StashIDDuplicationCriterionInput `json:"duplicated_stash_id"` + // Filter Scenes that have the same title + DuplicatedTitle *TitleDuplicationCriterionInput `json:"duplicated_title"` + // Filter Scenes that have the same URL + DuplicatedURL *URLDuplicationCriterionInput `json:"duplicated_url"` // Filter by resolution Resolution *ResolutionCriterionInput `json:"resolution"` // Filter by orientation diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 9cfe38b1f..04b7be9a1 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -12,6 +12,7 @@ import ( "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) @@ -246,8 +247,10 @@ func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, f } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := galleriesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := galleriesURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -286,7 +289,9 @@ func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Galler } if updatedObject.URLs.Loaded() { - if err := galleriesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := galleriesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } @@ -339,6 +344,15 @@ func (qb *GalleryStore) UpdatePartial(ctx context.Context, id int, partial model if err := galleriesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := galleriesURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := galleriesURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if partial.PerformerIDs != nil { if err := galleriesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index f0f8d6b40..fc3feb7d1 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -14,6 +14,7 @@ import ( "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) const ( @@ -172,8 +173,10 @@ func (qb *GroupStore) Create(ctx context.Context, newObject *models.Group) error } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := groupsURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := groupsURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -219,6 +222,15 @@ func (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models. if err := groupsURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := groupsURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := groupsURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if err := qb.tagRelationshipStore.modifyRelationships(ctx, id, partial.TagIDs); err != nil { @@ -245,7 +257,9 @@ func (qb *GroupStore) Update(ctx context.Context, updatedObject *models.Group) e } if updatedObject.URLs.Loaded() { - if err := groupsURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := groupsURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 1588fa415..bdad885e4 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -11,6 +11,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" + "github.com/stashapp/stash/pkg/utils" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" @@ -251,8 +252,10 @@ func (qb *ImageStore) Create(ctx context.Context, newObject *models.Image, fileI } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := imagesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := imagesURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -309,6 +312,15 @@ func (qb *ImageStore) UpdatePartial(ctx context.Context, id int, partial models. if err := imagesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := imagesURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := imagesURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if partial.PerformerIDs != nil { if err := imagesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { @@ -339,7 +351,9 @@ func (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) e } if updatedObject.URLs.Loaded() { - if err := imagesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := imagesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index c5943b182..821bbabe3 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -269,8 +269,10 @@ func (qb *PerformerStore) Create(ctx context.Context, newObject *models.CreatePe } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := performersURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := performersURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -327,6 +329,15 @@ func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial mod if err := performersURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := performersURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := performersURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if partial.TagIDs != nil { @@ -362,7 +373,9 @@ func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.Upda } if updatedObject.URLs.Loaded() { - if err := performersURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := performersURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index d5d8ce2fa..d0452d4ba 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -445,7 +445,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { url = "url" twitter = "twitter" instagram = "instagram" - urls = []string{url, twitter, instagram} + urls = []string{instagram, twitter, url} // sorted alphabetically rating = 3 ethnicity = "ethnicity" country = "country" diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 40feb5847..f4dd9537e 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -315,8 +315,10 @@ func (qb *SceneStore) Create(ctx context.Context, newObject *models.Scene, fileI } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := scenesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := scenesURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -379,6 +381,15 @@ func (qb *SceneStore) UpdatePartial(ctx context.Context, id int, partial models. if err := scenesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := scenesURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := scenesURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if partial.PerformerIDs != nil { if err := scenesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { @@ -423,7 +434,9 @@ func (qb *SceneStore) Update(ctx context.Context, updatedObject *models.Scene) e } if updatedObject.URLs.Loaded() { - if err := scenesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := scenesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index fad300248..559e2004b 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -156,6 +156,9 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite), qb.performerAgeCriterionHandler(sceneFilter.PerformerAge), qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable), + qb.stashIDDuplicatedCriterionHandler(sceneFilter.DuplicatedStashID), + qb.titleDuplicatedCriterionHandler(sceneFilter.DuplicatedTitle), + qb.urlDuplicatedCriterionHandler(sceneFilter.DuplicatedURL), &dateCriterionHandler{sceneFilter.Date, "scenes.date", nil}, ×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, ×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, @@ -297,6 +300,54 @@ func (qb *sceneFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter * } } +func (qb *sceneFilterHandler) stashIDDuplicatedCriterionHandler(duplicatedFilter *models.StashIDDuplicationCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if duplicatedFilter != nil && duplicatedFilter.Duplicated != nil { + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + + // Find stash_ids that appear on more than one scene + f.addInnerJoin("(SELECT scene_id FROM scene_stash_ids INNER JOIN (SELECT stash_id FROM scene_stash_ids GROUP BY stash_id HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_stash_ids.stash_id = dupes.stash_id)", "scsi", "scenes.id = scsi.scene_id") + } + } +} + +func (qb *sceneFilterHandler) titleDuplicatedCriterionHandler(duplicatedFilter *models.TitleDuplicationCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if duplicatedFilter != nil && duplicatedFilter.Duplicated != nil { + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + + // Find titles that appear on more than one scene (excluding empty titles) + f.addInnerJoin("(SELECT id FROM scenes WHERE title != '' AND title IS NOT NULL AND title IN (SELECT title FROM scenes WHERE title != '' AND title IS NOT NULL GROUP BY title HAVING COUNT(*) "+v+" 1))", "sctitle", "scenes.id = sctitle.id") + } + } +} + +func (qb *sceneFilterHandler) urlDuplicatedCriterionHandler(duplicatedFilter *models.URLDuplicationCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if duplicatedFilter != nil && duplicatedFilter.Duplicated != nil { + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + + // Find URLs that appear on more than one scene + f.addInnerJoin("(SELECT scene_id FROM scene_urls INNER JOIN (SELECT url FROM scene_urls GROUP BY url HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_urls.url = dupes.url)", "scurl", "scenes.id = scurl.scene_id") + } + } +} + func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if codec != nil { diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 1a05be6f3..149360d98 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -15,6 +15,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/studio" + "github.com/stashapp/stash/pkg/utils" ) const ( @@ -191,8 +192,10 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := studiosURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := studiosURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -241,6 +244,15 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar if err := studiosURLsTableMgr.modifyJoins(ctx, input.ID, input.URLs.Values, input.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := studiosURLsTableMgr.get(ctx, input.ID) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := studiosURLsTableMgr.replaceJoins(ctx, input.ID, urls); err != nil { + return nil, err + } } if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil { @@ -272,7 +284,9 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) } if updatedObject.URLs.Loaded() { - if err := studiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := studiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/utils/url.go b/pkg/utils/url.go index e4d2df237..632b64e56 100644 --- a/pkg/utils/url.go +++ b/pkg/utils/url.go @@ -1,6 +1,10 @@ package utils -import "regexp" +import ( + "regexp" + "sort" + "strings" +) // URLFromHandle adds the site URL to the input if the input is not already a URL // siteURL must not end with a slash @@ -13,3 +17,21 @@ func URLFromHandle(input string, siteURL string) string { return siteURL + "/" + input } + +// urlSortKey extracts the sortable portion of a URL by removing the protocol and www. prefix +func urlSortKey(url string) string { + // Remove http:// or https:// + key := strings.TrimPrefix(url, "https://") + key = strings.TrimPrefix(key, "http://") + // Remove www. prefix + key = strings.TrimPrefix(key, "www.") + return strings.ToLower(key) +} + +// SortURLs sorts a slice of URLs alphabetically by their base URL, +// excluding the protocol (http/https) and www. prefix +func SortURLs(urls []string) { + sort.SliceStable(urls, func(i, j int) bool { + return urlSortKey(urls[i]) < urlSortKey(urls[j]) + }) +} diff --git a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx new file mode 100644 index 000000000..3e67bdae6 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useIntl } from "react-intl"; +import { BooleanCriterion } from "src/models/list-filter/criteria/criterion"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Option, SelectedList } from "./SidebarListFilter"; +import { DuplicatedCriterionOption } from "src/models/list-filter/criteria/phash"; +import { DuplicatedStashIDCriterionOption } from "src/models/list-filter/criteria/stash-ids"; +import { DuplicatedTitleCriterionOption } from "src/models/list-filter/criteria/title"; +import { DuplicatedURLCriterionOption } from "src/models/list-filter/criteria/url"; +import { SidebarSection } from "src/components/Shared/Sidebar"; +import { Icon } from "src/components/Shared/Icon"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { keyboardClickHandler } from "src/utils/keyboard"; + +// Mapping of duplicate type IDs to their criterion options +const DUPLICATE_TYPES = { + phash: DuplicatedCriterionOption, + stash_id: DuplicatedStashIDCriterionOption, + title: DuplicatedTitleCriterionOption, + url: DuplicatedURLCriterionOption, +} as const; + +type DuplicateTypeId = keyof typeof DUPLICATE_TYPES; + +interface ISidebarDuplicateFilterProps { + title?: React.ReactNode; + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + sectionID?: string; +} + +// i18n message IDs for each duplicate type +const DUPLICATE_TYPE_MESSAGE_IDS: Record = { + phash: "media_info.phash", + stash_id: "stash_id", + title: "title", + url: "url", +}; + +export const SidebarDuplicateFilter: React.FC = ({ + title, + filter, + setFilter, + sectionID, +}) => { + const intl = useIntl(); + const [expandedType, setExpandedType] = useState(null); + + const trueLabel = intl.formatMessage({ id: "true" }); + const falseLabel = intl.formatMessage({ id: "false" }); + + // Get label for a duplicate type + const getLabel = useCallback( + (typeId: DuplicateTypeId) => + intl.formatMessage({ id: DUPLICATE_TYPE_MESSAGE_IDS[typeId] }), + [intl] + ); + + // Get criterion for a given type + const getCriterion = useCallback( + (typeId: DuplicateTypeId): BooleanCriterion | null => { + const criteria = filter.criteriaFor( + DUPLICATE_TYPES[typeId].type + ) as BooleanCriterion[]; + return criteria.length > 0 ? criteria[0] : null; + }, + [filter] + ); + + // Build selected items list + const selected: Option[] = useMemo(() => { + const result: Option[] = []; + + for (const typeId of Object.keys(DUPLICATE_TYPES) as DuplicateTypeId[]) { + const criterion = getCriterion(typeId); + if (criterion) { + const valueLabel = criterion.value === "true" ? trueLabel : falseLabel; + result.push({ + id: typeId, + label: `${getLabel(typeId)}: ${valueLabel}`, + }); + } + } + + return result; + }, [getCriterion, trueLabel, falseLabel, getLabel]); + + // Available options - show options that aren't already selected + const options = useMemo(() => { + const result: { id: DuplicateTypeId; label: string }[] = []; + + for (const typeId of Object.keys(DUPLICATE_TYPES) as DuplicateTypeId[]) { + if (!getCriterion(typeId)) { + result.push({ id: typeId, label: getLabel(typeId) }); + } + } + + return result; + }, [getCriterion, getLabel]); + + function onToggleExpand(id: string) { + setExpandedType(expandedType === id ? null : id); + } + + function onUnselect(item: Option) { + const typeId = item.id as DuplicateTypeId; + const criterionOption = DUPLICATE_TYPES[typeId]; + if (criterionOption) { + setFilter(filter.removeCriterion(criterionOption.type)); + } + setExpandedType(null); + } + + function onSelectValue(typeId: string, value: "true" | "false") { + const criterionOption = DUPLICATE_TYPES[typeId as DuplicateTypeId]; + if (!criterionOption) return; + + const existingCriterion = getCriterion(typeId as DuplicateTypeId); + const newCriterion = existingCriterion + ? existingCriterion.clone() + : criterionOption.makeCriterion(); + newCriterion.value = value; + setFilter(filter.replaceCriteria(criterionOption.type, [newCriterion])); + setExpandedType(null); + } + + return ( + onUnselect(i)} /> + } + > +
+ +
+
+ ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 5f1b4da2a..513148b19 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -726,6 +726,24 @@ input[type="range"].zoom-slider { min-height: 2em; } +.duplicate-sub-options { + margin-left: 2rem; + padding-left: 0.5rem; + + .duplicate-sub-option { + align-items: center; + cursor: pointer; + display: flex; + height: 2em; + opacity: 0.8; + padding-left: 0.5rem; + + &:hover { + background-color: rgba(138, 155, 168, 0.15); + } + } +} + .tilted { transform: rotate(45deg); } diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index fa390d187..214068fc0 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -60,6 +60,7 @@ import { DurationCriterionOption, PerformerAgeCriterionOption, } from "src/models/list-filter/scenes"; +import { SidebarDuplicateFilter } from "../List/Filters/DuplicateFilter"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter"; import { @@ -347,6 +348,12 @@ const SidebarContent: React.FC<{ setFilter={setFilter} sectionID="organized" /> + } + filter={filter} + setFilter={setFilter} + sectionID="duplicated" + /> } option={PerformerAgeCriterionOption} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 54982b932..f6fe44a1a 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1062,7 +1062,11 @@ "select_youngest": "Select the youngest file in the duplicate group", "title": "Duplicate Scenes" }, + "duplicated": "Duplicated", "duplicated_phash": "Duplicated (pHash)", + "duplicated_stash_id": "Duplicated (Stash ID)", + "duplicated_title": "Duplicated (Title)", + "duplicated_url": "Duplicated (URL)", "duration": "Duration", "effect_filters": { "aspect": "Aspect", diff --git a/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts index 1800930fb..24b68366b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts +++ b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts @@ -3,12 +3,15 @@ import { IntlShape } from "react-intl"; import { CriterionModifier, StashIdCriterionInput, + StashIdDuplicationCriterionInput, } from "src/core/generated-graphql"; import { IStashIDValue } from "../types"; import { + BooleanCriterionOption, ISavedCriterion, ModifierCriterion, ModifierCriterionOption, + StringCriterion, } from "./criterion"; export const StashIDCriterionOption = new ModifierCriterionOption({ @@ -145,3 +148,21 @@ export class StashIDCriterion extends ModifierCriterion { ); } } + +export const DuplicatedStashIDCriterionOption = new BooleanCriterionOption( + "duplicated_stash_id", + "duplicated_stash_id", + () => new DuplicatedStashIDCriterion() +); + +export class DuplicatedStashIDCriterion extends StringCriterion { + constructor() { + super(DuplicatedStashIDCriterionOption); + } + + public toCriterionInput(): StashIdDuplicationCriterionInput { + return { + duplicated: this.value === "true", + }; + } +} diff --git a/ui/v2.5/src/models/list-filter/criteria/title.ts b/ui/v2.5/src/models/list-filter/criteria/title.ts new file mode 100644 index 000000000..46345741e --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/title.ts @@ -0,0 +1,20 @@ +import { TitleDuplicationCriterionInput } from "src/core/generated-graphql"; +import { BooleanCriterionOption, StringCriterion } from "./criterion"; + +export const DuplicatedTitleCriterionOption = new BooleanCriterionOption( + "duplicated_title", + "duplicated_title", + () => new DuplicatedTitleCriterion() +); + +export class DuplicatedTitleCriterion extends StringCriterion { + constructor() { + super(DuplicatedTitleCriterionOption); + } + + public toCriterionInput(): TitleDuplicationCriterionInput { + return { + duplicated: this.value === "true", + }; + } +} diff --git a/ui/v2.5/src/models/list-filter/criteria/url.ts b/ui/v2.5/src/models/list-filter/criteria/url.ts new file mode 100644 index 000000000..b5877e255 --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/url.ts @@ -0,0 +1,20 @@ +import { UrlDuplicationCriterionInput } from "src/core/generated-graphql"; +import { BooleanCriterionOption, StringCriterion } from "./criterion"; + +export const DuplicatedURLCriterionOption = new BooleanCriterionOption( + "duplicated_url", + "duplicated_url", + () => new DuplicatedURLCriterion() +); + +export class DuplicatedURLCriterion extends StringCriterion { + constructor() { + super(DuplicatedURLCriterionOption); + } + + public toCriterionInput(): UrlDuplicationCriterionInput { + return { + duplicated: this.value === "true", + }; + } +} diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index cf2791567..a405ad536 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -31,7 +31,12 @@ import { } from "./criteria/phash"; import { PerformerFavoriteCriterionOption } from "./criteria/favorite"; import { CaptionsCriterionOption } from "./criteria/captions"; -import { StashIDCriterionOption } from "./criteria/stash-ids"; +import { + DuplicatedStashIDCriterionOption, + StashIDCriterionOption, +} from "./criteria/stash-ids"; +import { DuplicatedTitleCriterionOption } from "./criteria/title"; +import { DuplicatedURLCriterionOption } from "./criteria/url"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { OrientationCriterionOption } from "./criteria/orientation"; @@ -100,6 +105,9 @@ const criterionOptions = [ createStringCriterionOption("checksum", "media_info.checksum"), PhashCriterionOption, DuplicatedCriterionOption, + DuplicatedStashIDCriterionOption, + DuplicatedTitleCriterionOption, + DuplicatedURLCriterionOption, OrganizedCriterionOption, RatingCriterionOption, createMandatoryNumberCriterionOption("o_counter", "o_count", { diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 83ebaa010..db58f65fc 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -197,6 +197,9 @@ export type CriterionType = | "favorite" | "performer_age" | "duplicated" + | "duplicated_stash_id" + | "duplicated_title" + | "duplicated_url" | "ignore_auto_tag" | "file_count" | "stash_id_endpoint"