diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 04a28171c..a7fecca20 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -75,10 +75,26 @@ input OrientationCriterionInput { value: [OrientationEnum!]! } -input PHashDuplicationCriterionInput { - duplicated: Boolean - "Currently unimplemented" +input DuplicationCriterionInput { + duplicated: Boolean @deprecated(reason: "Use phash field instead") + "Currently unimplemented. Intended for phash distance matching." distance: Int + "Filter by phash duplication" + phash: Boolean + "Filter by URL duplication" + url: Boolean + "Filter by Stash ID duplication" + stash_id: Boolean + "Filter by title duplication" + title: Boolean +} + +input FileDuplicationCriterionInput { + duplicated: Boolean @deprecated(reason: "Use phash field instead") + "Currently unimplemented. Intended for phash distance matching." + distance: Int + "Filter by phash duplication" + phash: Boolean } input StashIDCriterionInput { @@ -261,8 +277,8 @@ input SceneFilterType { organized: Boolean "Filter by o-counter" o_counter: IntCriterionInput - "Filter Scenes that have an exact phash match available" - duplicated: PHashDuplicationCriterionInput + "Filter Scenes by duplication criteria" + duplicated: DuplicationCriterionInput "Filter by resolution" resolution: ResolutionCriterionInput "Filter by orientation" @@ -744,8 +760,8 @@ input FileFilterType { "Filter by modification time" mod_time: TimestampCriterionInput - "Filter files that have an exact match available" - duplicated: PHashDuplicationCriterionInput + "Filter files by duplication criteria (only phash applies to files)" + duplicated: FileDuplicationCriterionInput "find files based on hash" hashes: [FingerprintFilterInput!] diff --git a/pkg/models/file.go b/pkg/models/file.go index 63c30ba4d..32263319c 100644 --- a/pkg/models/file.go +++ b/pkg/models/file.go @@ -26,7 +26,7 @@ type FileFilterType struct { ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"` ZipFile *MultiCriterionInput `json:"zip_file"` ModTime *TimestampCriterionInput `json:"mod_time"` - Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` + Duplicated *FileDuplicationCriterionInput `json:"duplicated"` Hashes []*FingerprintFilterInput `json:"hashes"` VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"` ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"` diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 434659cbe..22863c4d9 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -2,10 +2,28 @@ package models import "context" -type PHashDuplicationCriterionInput struct { +type DuplicationCriterionInput struct { + // Deprecated: Use Phash field instead. Kept for backwards compatibility. Duplicated *bool `json:"duplicated"` - // Currently unimplemented + // Currently unimplemented. Intended for phash distance matching. Distance *int `json:"distance"` + // Filter by phash duplication + Phash *bool `json:"phash"` + // Filter by URL duplication + URL *bool `json:"url"` + // Filter by Stash ID duplication + StashID *bool `json:"stash_id"` + // Filter by title duplication + Title *bool `json:"title"` +} + +type FileDuplicationCriterionInput struct { + // Deprecated: Use Phash field instead. Kept for backwards compatibility. + Duplicated *bool `json:"duplicated"` + // Currently unimplemented. Intended for phash distance matching. + Distance *int `json:"distance"` + // Filter by phash duplication + Phash *bool `json:"phash"` } type SceneFilterType struct { @@ -33,8 +51,8 @@ type SceneFilterType struct { Organized *bool `json:"organized"` // Filter by o-counter OCounter *IntCriterionInput `json:"o_counter"` - // Filter Scenes that have an exact phash match available - Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` + // Filter Scenes by duplication criteria + Duplicated *DuplicationCriterionInput `json:"duplicated"` // Filter by resolution Resolution *ResolutionCriterionInput `json:"resolution"` // Filter by orientation diff --git a/pkg/sqlite/file_filter.go b/pkg/sqlite/file_filter.go index 12c7ba3d5..157efb1d8 100644 --- a/pkg/sqlite/file_filter.go +++ b/pkg/sqlite/file_filter.go @@ -82,7 +82,7 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler { qb.hashesCriterionHandler(fileFilter.Hashes), - qb.phashDuplicatedCriterionHandler(fileFilter.Duplicated), + qb.duplicatedCriterionHandler(fileFilter.Duplicated), ×tampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil}, ×tampCriterionHandler{fileFilter.UpdatedAt, "files.updated_at", nil}, @@ -205,17 +205,27 @@ func (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterion return h.handler(c) } -func (qb *fileFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc { +func (qb *fileFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.FileDuplicationCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { // TODO: Wishlist item: Implement Distance matching - if duplicatedFilter != nil { - var v string - if *duplicatedFilter.Duplicated { - v = ">" - } else { - v = "=" - } + // For files, only phash duplication applies + if duplicatedFilter == nil { + return + } + var phashValue *bool + + // Handle legacy 'duplicated' field for backwards compatibility + //nolint:staticcheck + if duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil { + //nolint:staticcheck + phashValue = duplicatedFilter.Duplicated + } else if duplicatedFilter.Phash != nil { + phashValue = duplicatedFilter.Phash + } + + if phashValue != nil { + v := getCountOperator(*phashValue) f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "files.id = scph.file_id") } } diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index aa0d349df..e42376950 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -174,7 +174,7 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { qb.performerTagsCriterionHandler(sceneFilter.PerformerTags), qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite), qb.performerAgeCriterionHandler(sceneFilter.PerformerAge), - qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable), + qb.duplicatedCriterionHandler(sceneFilter.Duplicated), &dateCriterionHandler{sceneFilter.Date, "scenes.date", nil}, ×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, ×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, @@ -296,26 +296,71 @@ func (qb *sceneFilterHandler) fileCountCriterionHandler(fileCount *models.IntCri return h.handler(fileCount) } -func (qb *sceneFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func (qb *sceneFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.DuplicationCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - // TODO: Wishlist item: Implement Distance matching - if duplicatedFilter != nil { - if addJoinFn != nil { - addJoinFn(f) - } + if duplicatedFilter == nil { + return + } - var v string - if *duplicatedFilter.Duplicated { - v = ">" - } else { - v = "=" - } + // Handle legacy 'duplicated' field - treat as phash if phash not explicitly set + //nolint:staticcheck + if duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil { + //nolint:staticcheck + duplicatedFilter.Phash = duplicatedFilter.Duplicated + } - f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id") + // Handle explicit fields + if duplicatedFilter.Phash != nil { + qb.addSceneFilesTable(f) + qb.applyPhashDuplication(f, *duplicatedFilter.Phash) + } + + if duplicatedFilter.StashID != nil { + qb.applyStashIDDuplication(f, *duplicatedFilter.StashID) + } + + if duplicatedFilter.Title != nil { + qb.applyTitleDuplication(f, *duplicatedFilter.Title) + } + + if duplicatedFilter.URL != nil { + qb.applyURLDuplication(f, *duplicatedFilter.URL) } } } +// getCountOperator returns ">" for duplicated items (count > 1) or "=" for unique items (count = 1) +func getCountOperator(duplicated bool) string { + if duplicated { + return ">" + } + return "=" +} + +func (qb *sceneFilterHandler) applyPhashDuplication(f *filterBuilder, duplicated bool) { + // TODO: Wishlist item: Implement Distance matching + v := getCountOperator(duplicated) + f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id") +} + +func (qb *sceneFilterHandler) applyStashIDDuplication(f *filterBuilder, duplicated bool) { + v := getCountOperator(duplicated) + // 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) applyTitleDuplication(f *filterBuilder, duplicated bool) { + v := getCountOperator(duplicated) + // 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) applyURLDuplication(f *filterBuilder, duplicated bool) { + v := getCountOperator(duplicated) + // 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/scene_test.go b/pkg/sqlite/scene_test.go index ae9ba56cf..6cdb62a5e 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -4121,7 +4121,7 @@ func TestSceneQueryPhashDuplicated(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene duplicated := true - phashCriterion := models.PHashDuplicationCriterionInput{ + phashCriterion := models.DuplicationCriterionInput{ Duplicated: &duplicated, } diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index eba212223..8795296fa 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -42,8 +42,12 @@ import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import StudiosFilter from "./Filters/StudiosFilter"; import { TagsCriterion } from "src/models/list-filter/criteria/tags"; import TagsFilter from "./Filters/TagsFilter"; -import { PhashCriterion } from "src/models/list-filter/criteria/phash"; +import { + PhashCriterion, + DuplicatedCriterion, +} from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; +import { DuplicatedFilter } from "./Filters/DuplicateFilter"; import { PathCriterion } from "src/models/list-filter/criteria/path"; import { ModifierSelectorButtons } from "./ModifierSelect"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; @@ -273,6 +277,12 @@ export const CriterionEditor: React.FC = ({ ); } + if (criterion instanceof DuplicatedCriterion) { + return ( + + ); + } + if (criterion instanceof CustomFieldsCriterion) { return ( 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..819d5b885 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx @@ -0,0 +1,227 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useIntl } from "react-intl"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Option, SelectedList } from "./SidebarListFilter"; +import { + DuplicatedCriterion, + DuplicatedCriterionOption, + DuplicationFieldId, + DUPLICATION_FIELD_IDS, + DUPLICATION_FIELD_MESSAGE_IDS, +} from "src/models/list-filter/criteria/phash"; +import { IndeterminateCheckbox } from "src/components/Shared/IndeterminateCheckbox"; +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"; + +interface IDuplicatedFilter { + criterion: DuplicatedCriterion; + setCriterion: (c: DuplicatedCriterion) => void; +} + +export const DuplicatedFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + const intl = useIntl(); + + function onFieldChange( + fieldId: DuplicationFieldId, + value: boolean | undefined + ) { + const c = criterion.clone(); + if (value === undefined) { + delete c.value[fieldId]; + } else { + c.value[fieldId] = value; + } + setCriterion(c); + } + + return ( +
+ {DUPLICATION_FIELD_IDS.map((fieldId) => ( + onFieldChange(fieldId, v)} + /> + ))} +
+ ); +}; + +interface ISidebarDuplicateFilterProps { + title?: React.ReactNode; + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + sectionID?: string; +} + +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: DuplicationFieldId) => + intl.formatMessage({ id: DUPLICATION_FIELD_MESSAGE_IDS[typeId] }), + [intl] + ); + + // Get the single duplicated criterion from the filter + const getCriterion = useCallback((): DuplicatedCriterion | null => { + const criteria = filter.criteriaFor( + DuplicatedCriterionOption.type + ) as DuplicatedCriterion[]; + return criteria.length > 0 ? criteria[0] : null; + }, [filter]); + + // Get value for a specific type from the criterion + const getTypeValue = useCallback( + (typeId: DuplicationFieldId): boolean | undefined => { + const criterion = getCriterion(); + if (!criterion) return undefined; + return criterion.value[typeId]; + }, + [getCriterion] + ); + + // Build selected items list + const selected: Option[] = useMemo(() => { + const result: Option[] = []; + const criterion = getCriterion(); + if (!criterion) return result; + + for (const typeId of DUPLICATION_FIELD_IDS) { + const value = criterion.value[typeId]; + if (value !== undefined) { + const valueLabel = value ? 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: DuplicationFieldId; label: string }[] = []; + + for (const typeId of DUPLICATION_FIELD_IDS) { + if (getTypeValue(typeId) === undefined) { + result.push({ id: typeId, label: getLabel(typeId) }); + } + } + + return result; + }, [getTypeValue, getLabel]); + + function onToggleExpand(id: string) { + setExpandedType(expandedType === id ? null : id); + } + + function onUnselect(item: Option) { + const typeId = item.id as DuplicationFieldId; + const criterion = getCriterion(); + + if (!criterion) return; + + const newCriterion = criterion.clone(); + delete newCriterion.value[typeId]; + + // If no fields are set, remove the criterion entirely + const hasAnyValue = DUPLICATION_FIELD_IDS.some( + (id) => newCriterion.value[id] !== undefined + ); + + if (!hasAnyValue) { + setFilter(filter.removeCriterion(DuplicatedCriterionOption.type)); + } else { + setFilter( + filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion]) + ); + } + setExpandedType(null); + } + + function onSelectValue(typeId: string, value: boolean) { + const criterion = getCriterion(); + const newCriterion = criterion + ? criterion.clone() + : (DuplicatedCriterionOption.makeCriterion() as DuplicatedCriterion); + + newCriterion.value[typeId as DuplicationFieldId] = value; + setFilter( + filter.replaceCriteria(DuplicatedCriterionOption.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 8a7fdf8cf..e7a4caf02 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 2f74fc7e4..79f470de8 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -44,6 +44,7 @@ import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organi import { HasMarkersCriterionOption } from "src/models/list-filter/criteria/has-markers"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { 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 { @@ -320,6 +321,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 6f7403686..b8800216c 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1092,7 +1092,10 @@ "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)", "duration": "Duration", "effect_filters": { "aspect": "Aspect", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 8f30e5d17..ae23a48d4 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -12,6 +12,7 @@ import { import TextUtils from "src/utils/text"; import { CriterionType, + IDuplicationValue, IHierarchicalLabelValue, ILabeledId, INumberValue, @@ -36,7 +37,8 @@ export type CriterionValue = | IStashIDValue | IDateValue | ITimestampValue - | IPhashDistanceValue; + | IPhashDistanceValue + | IDuplicationValue; export interface ISavedCriterion { modifier: CriterionModifier; diff --git a/ui/v2.5/src/models/list-filter/criteria/phash.ts b/ui/v2.5/src/models/list-filter/criteria/phash.ts index 0cbfa155e..e79b0a447 100644 --- a/ui/v2.5/src/models/list-filter/criteria/phash.ts +++ b/ui/v2.5/src/models/list-filter/criteria/phash.ts @@ -1,15 +1,28 @@ import { CriterionModifier, PhashDistanceCriterionInput, - PHashDuplicationCriterionInput, + DuplicationCriterionInput, } from "src/core/generated-graphql"; -import { IPhashDistanceValue } from "../types"; -import { - BooleanCriterionOption, - ModifierCriterion, - ModifierCriterionOption, - StringCriterion, -} from "./criterion"; +import { IDuplicationValue, IPhashDistanceValue } from "../types"; +import { ModifierCriterion, ModifierCriterionOption } from "./criterion"; +import { IntlShape } from "react-intl"; + +// Shared mapping of duplication field IDs to their i18n message IDs +export const DUPLICATION_FIELD_MESSAGE_IDS = { + phash: "media_info.phash", + stash_id: "stash_id", + title: "title", + url: "url", +} as const; + +export type DuplicationFieldId = keyof typeof DUPLICATION_FIELD_MESSAGE_IDS; + +export const DUPLICATION_FIELD_IDS: DuplicationFieldId[] = [ + "phash", + "stash_id", + "title", + "url", +]; export const PhashCriterionOption = new ModifierCriterionOption({ messageID: "media_info.phash", @@ -55,20 +68,97 @@ export class PhashCriterion extends ModifierCriterion { } } -export const DuplicatedCriterionOption = new BooleanCriterionOption( - "duplicated_phash", - "duplicated", - () => new DuplicatedCriterion() -); +export const DuplicatedCriterionOption = new ModifierCriterionOption({ + messageID: "duplicated", + type: "duplicated", + modifierOptions: [], // No modifiers for this filter + defaultModifier: CriterionModifier.Equals, + makeCriterion: () => new DuplicatedCriterion(), +}); -export class DuplicatedCriterion extends StringCriterion { +export class DuplicatedCriterion extends ModifierCriterion { constructor() { - super(DuplicatedCriterionOption); + super(DuplicatedCriterionOption, {}); } - public toCriterionInput(): PHashDuplicationCriterionInput { + public cloneValues() { + this.value = { ...this.value }; + } + + // Override getLabel to provide custom formatting for duplication fields + public getLabel(intl: IntlShape): string { + const parts: string[] = []; + const trueLabel = intl.formatMessage({ id: "true" }); + const falseLabel = intl.formatMessage({ id: "false" }); + + for (const fieldId of DUPLICATION_FIELD_IDS) { + const fieldValue = this.value[fieldId]; + if (fieldValue !== undefined) { + const label = intl.formatMessage({ + id: DUPLICATION_FIELD_MESSAGE_IDS[fieldId], + }); + parts.push(`${label}: ${fieldValue ? trueLabel : falseLabel}`); + } + } + + // Handle legacy duplicated field + if (parts.length === 0 && this.value.duplicated !== undefined) { + const label = intl.formatMessage({ id: "duplicated_phash" }); + return `${label}: ${this.value.duplicated ? trueLabel : falseLabel}`; + } + + if (parts.length === 0) { + return intl.formatMessage({ id: "duplicated" }); + } + + return parts.join(", "); + } + + protected getLabelValue(intl: IntlShape): string { + // Required by abstract class - returns basic label when getLabel isn't overridden + return intl.formatMessage({ id: "duplicated" }); + } + + protected toCriterionInput(): DuplicationCriterionInput { return { - duplicated: this.value === "true", + duplicated: this.value.duplicated, + distance: this.value.distance, + phash: this.value.phash, + url: this.value.url, + stash_id: this.value.stash_id, + title: this.value.title, }; } + + // Override to handle legacy saved formats + public setFromSavedCriterion(criterion: unknown): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = criterion as any; + + // Handle various saved formats + if (c.value !== undefined) { + // New format: { value: { phash: true, ... } } + if (typeof c.value === "object") { + this.value = c.value as IDuplicationValue; + } else if (typeof c.value === "string") { + // Legacy format: { value: "true" } - convert to phash + this.value = { phash: c.value === "true" }; + } + } else if (typeof c === "object") { + // Direct value format + this.value = c as IDuplicationValue; + } + + if (c.modifier) { + this.modifier = c.modifier; + } + } + + public isValid(): boolean { + // Check if any duplication field is set + const hasFieldSet = DUPLICATION_FIELD_IDS.some( + (fieldId) => this.value[fieldId] !== undefined + ); + return hasFieldSet || this.value.duplicated !== undefined; + } } diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index bf5fff4d9..442099a53 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -47,9 +47,15 @@ export interface IRangeValue { export type INumberValue = IRangeValue; export type IDateValue = IRangeValue; export type ITimestampValue = IRangeValue; -export interface IPHashDuplicationValue { - duplicated: boolean; - distance?: number; // currently not implemented +export interface IDuplicationValue { + // Deprecated: Use phash field instead. Kept for backwards compatibility. + duplicated?: boolean; + // Currently not implemented. Intended for phash distance matching. + distance?: number; + phash?: boolean; + url?: boolean; + stash_id?: boolean; + title?: boolean; } export interface IStashIDValue {