mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
Merge e057b2899b into 39fd8a6550
This commit is contained in:
commit
2c9c20c1ab
20 changed files with 484 additions and 15 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
}
|
||||
|
|
|
|||
177
ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx
Normal file
177
ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx
Normal file
|
|
@ -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<DuplicateTypeId, string> = {
|
||||
phash: "media_info.phash",
|
||||
stash_id: "stash_id",
|
||||
title: "title",
|
||||
url: "url",
|
||||
};
|
||||
|
||||
export const SidebarDuplicateFilter: React.FC<ISidebarDuplicateFilterProps> = ({
|
||||
title,
|
||||
filter,
|
||||
setFilter,
|
||||
sectionID,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [expandedType, setExpandedType] = useState<string | null>(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 (
|
||||
<SidebarSection
|
||||
className="sidebar-list-filter"
|
||||
text={title}
|
||||
sectionID={sectionID}
|
||||
outsideCollapse={
|
||||
<SelectedList items={selected} onUnselect={(i) => onUnselect(i)} />
|
||||
}
|
||||
>
|
||||
<div className="queryable-candidate-list">
|
||||
<ul>
|
||||
{options.map((opt) => (
|
||||
<React.Fragment key={opt.id}>
|
||||
<li className="unselected-object">
|
||||
<a
|
||||
onClick={() => onToggleExpand(opt.id)}
|
||||
onKeyDown={keyboardClickHandler(() => onToggleExpand(opt.id))}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="label-group">
|
||||
<Icon
|
||||
className="fa-fw include-button single-value"
|
||||
icon={faPlus}
|
||||
/>
|
||||
<span className="unselected-object-label">{opt.label}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{expandedType === opt.id && (
|
||||
<div className="duplicate-sub-options">
|
||||
<div
|
||||
className="duplicate-sub-option"
|
||||
onClick={() => onSelectValue(opt.id, "true")}
|
||||
>
|
||||
{trueLabel}
|
||||
</div>
|
||||
<div
|
||||
className="duplicate-sub-option"
|
||||
onClick={() => onSelectValue(opt.id, "false")}
|
||||
>
|
||||
{falseLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
<SidebarDuplicateFilter
|
||||
title={<FormattedMessage id="duplicated" />}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="duplicated"
|
||||
/>
|
||||
<SidebarAgeFilter
|
||||
title={<FormattedMessage id="performer_age" />}
|
||||
option={PerformerAgeCriterionOption}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<IStashIDValue> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
ui/v2.5/src/models/list-filter/criteria/title.ts
Normal file
20
ui/v2.5/src/models/list-filter/criteria/title.ts
Normal file
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
20
ui/v2.5/src/models/list-filter/criteria/url.ts
Normal file
20
ui/v2.5/src/models/list-filter/criteria/url.ts
Normal file
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue