Feature: Scene Duplicate Filter (#6344)

This commit is contained in:
Gykes 2026-02-10 18:52:44 -06:00 committed by GitHub
parent 26db935fad
commit d1479ca4e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 510 additions and 58 deletions

View file

@ -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!]

View file

@ -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"`

View file

@ -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

View file

@ -82,7 +82,7 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler {
qb.hashesCriterionHandler(fileFilter.Hashes),
qb.phashDuplicatedCriterionHandler(fileFilter.Duplicated),
qb.duplicatedCriterionHandler(fileFilter.Duplicated),
&timestampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil},
&timestampCriterionHandler{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")
}
}

View file

@ -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},
&timestampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil},
&timestampCriterionHandler{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 {

View file

@ -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,
}

View file

@ -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<ICriterionEditor> = ({
);
}
if (criterion instanceof DuplicatedCriterion) {
return (
<DuplicatedFilter criterion={criterion} setCriterion={setCriterion} />
);
}
if (criterion instanceof CustomFieldsCriterion) {
return (
<CustomFieldsFilter criterion={criterion} setCriterion={setCriterion} />

View file

@ -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<IDuplicatedFilter> = ({
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 (
<div className="duplicated-filter">
{DUPLICATION_FIELD_IDS.map((fieldId) => (
<IndeterminateCheckbox
key={fieldId}
id={`duplicated-${fieldId}`}
label={intl.formatMessage({
id: DUPLICATION_FIELD_MESSAGE_IDS[fieldId],
})}
checked={criterion.value[fieldId]}
setChecked={(v) => onFieldChange(fieldId, v)}
/>
))}
</div>
);
};
interface ISidebarDuplicateFilterProps {
title?: React.ReactNode;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
sectionID?: string;
}
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: 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 (
<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>
);
};

View file

@ -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);
}

View file

@ -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"
/>
<SidebarDuplicateFilter
title={<FormattedMessage id="duplicated" />}
filter={filter}
setFilter={setFilter}
sectionID="duplicated"
/>
<SidebarAgeFilter
title={<FormattedMessage id="performer_age" />}
option={PerformerAgeCriterionOption}

View file

@ -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",

View file

@ -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<T> {
modifier: CriterionModifier;

View file

@ -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<IPhashDistanceValue> {
}
}
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<IDuplicationValue> {
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;
}
}

View file

@ -47,9 +47,15 @@ export interface IRangeValue<V> {
export type INumberValue = IRangeValue<number>;
export type IDateValue = IRangeValue<string>;
export type ITimestampValue = IRangeValue<string>;
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 {