diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 00b8cc1a7..21ac7ad7f 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -105,6 +105,14 @@ input PerformerFilterType { studios: HierarchicalMultiCriterionInput """Filter by autotag ignore value""" ignore_auto_tag: Boolean + """Filter by birthdate""" + birthdate: DateCriterionInput + """Filter by death date""" + death_date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input SceneMarkerFilterType { @@ -116,6 +124,16 @@ input SceneMarkerFilterType { scene_tags: HierarchicalMultiCriterionInput """Filter to only include scene markers with these performers""" performers: MultiCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput + """Filter by scene date""" + scene_date: DateCriterionInput + """Filter by cscene reation time""" + scene_created_at: TimestampCriterionInput + """Filter by lscene ast update time""" + scene_updated_at: TimestampCriterionInput } input SceneFilterType { @@ -183,6 +201,12 @@ input SceneFilterType { interactive_speed: IntCriterionInput """Filter by captions""" captions: StringCriterionInput + """Filter by date""" + date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input MovieFilterType { @@ -203,6 +227,12 @@ input MovieFilterType { url: StringCriterionInput """Filter to only include movies where performer appears in a scene""" performers: MultiCriterionInput + """Filter by date""" + date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input StudioFilterType { @@ -232,6 +262,10 @@ input StudioFilterType { aliases: StringCriterionInput """Filter by autotag ignore value""" ignore_auto_tag: Boolean + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input GalleryFilterType { @@ -279,6 +313,12 @@ input GalleryFilterType { image_count: IntCriterionInput """Filter by url""" url: StringCriterionInput + """Filter by date""" + date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input TagFilterType { @@ -327,6 +367,12 @@ input TagFilterType { """Filter by autotag ignore value""" ignore_auto_tag: Boolean + + """Filter by creation time""" + created_at: TimestampCriterionInput + + """Filter by last update time""" + updated_at: TimestampCriterionInput } input ImageFilterType { @@ -370,6 +416,10 @@ input ImageFilterType { performer_favorite: Boolean """Filter to only include images with these galleries""" galleries: MultiCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } enum CriterionModifier { @@ -426,6 +476,18 @@ input HierarchicalMultiCriterionInput { depth: Int } +input DateCriterionInput { + value: String! + value2: String + modifier: CriterionModifier! +} + +input TimestampCriterionInput { + value: String! + value2: String + modifier: CriterionModifier! +} + enum FilterMode { SCENES, PERFORMERS, diff --git a/pkg/models/filter.go b/pkg/models/filter.go index ad5db4282..d614f262e 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -124,3 +124,15 @@ type MultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` } + +type DateCriterionInput struct { + Value string `json:"value"` + Value2 *string `json:"value2"` + Modifier CriterionModifier `json:"modifier"` +} + +type TimestampCriterionInput struct { + Value string `json:"value"` + Value2 *string `json:"value2"` + Modifier CriterionModifier `json:"modifier"` +} diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 3ec1d4378..4818e1ab9 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -49,6 +49,12 @@ type GalleryFilterType struct { ImageCount *IntCriterionInput `json:"image_count"` // Filter by url URL *StringCriterionInput `json:"url"` + // Filter by date + Date *DateCriterionInput `json:"date"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type GalleryUpdateInput struct { diff --git a/pkg/models/image.go b/pkg/models/image.go index c0775b182..57e47ad30 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -40,6 +40,10 @@ type ImageFilterType struct { PerformerFavorite *bool `json:"performer_favorite"` // Filter to only include images with these galleries Galleries *MultiCriterionInput `json:"galleries"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type ImageDestroyInput struct { diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 3fc1890a6..e577d79e7 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -18,6 +18,12 @@ type MovieFilterType struct { URL *StringCriterionInput `json:"url"` // Filter to only include movies where performer appears in a scene Performers *MultiCriterionInput `json:"performers"` + // Filter by date + Date *DateCriterionInput `json:"date"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type MovieReader interface { diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 191f6b4b9..99b2c84c0 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -125,6 +125,14 @@ type PerformerFilterType struct { Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by birthdate + Birthdate *DateCriterionInput `json:"birth_date"` + // Filter by death date + DeathDate *DateCriterionInput `json:"death_date"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type PerformerFinder interface { diff --git a/pkg/models/scene.go b/pkg/models/scene.go index e8651b15d..6483a5c69 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -75,6 +75,12 @@ type SceneFilterType struct { InteractiveSpeed *IntCriterionInput `json:"interactive_speed"` Captions *StringCriterionInput `json:"captions"` + // Filter by date + Date *DateCriterionInput `json:"date"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type SceneQueryOptions struct { diff --git a/pkg/models/scene_marker.go b/pkg/models/scene_marker.go index dd0b786f6..3251f6a00 100644 --- a/pkg/models/scene_marker.go +++ b/pkg/models/scene_marker.go @@ -11,6 +11,16 @@ type SceneMarkerFilterType struct { SceneTags *HierarchicalMultiCriterionInput `json:"scene_tags"` // Filter to only include scene markers with these performers Performers *MultiCriterionInput `json:"performers"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` + // Filter by scenes date + SceneDate *DateCriterionInput `json:"scene_date"` + // Filter by scenes created at + SceneCreatedAt *TimestampCriterionInput `json:"scene_created_at"` + // Filter by scenes updated at + SceneUpdatedAt *TimestampCriterionInput `json:"scene_updated_at"` } type MarkerStringsResultType struct { diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 50f8c12b4..661e80806 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -28,6 +28,10 @@ type StudioFilterType struct { Aliases *StringCriterionInput `json:"aliases"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type StudioFinder interface { diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 5a98fe676..440d147d3 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -34,6 +34,10 @@ type TagFilterType struct { ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type TagFinder interface { diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d3bc32144..bce9ad52b 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -543,6 +543,24 @@ func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilde } } +func dateCriterionHandler(c *models.DateCriterionInput, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + clause, args := getDateCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + +func timestampCriterionHandler(c *models.TimestampCriterionInput, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + clause, args := getTimestampCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + // handle for MultiCriterion where there is a join table between the new // objects type joinedMultiCriterionHandlerBuilder struct { diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index ade0915e7..829dab5ae 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -665,6 +665,9 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga query.handleCriterion(ctx, galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount)) query.handleCriterion(ctx, galleryPerformerFavoriteCriterionHandler(galleryFilter.PerformerFavorite)) query.handleCriterion(ctx, galleryPerformerAgeCriterionHandler(galleryFilter.PerformerAge)) + query.handleCriterion(ctx, dateCriterionHandler(galleryFilter.Date, "galleries.date")) + query.handleCriterion(ctx, timestampCriterionHandler(galleryFilter.CreatedAt, "galleries.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(galleryFilter.UpdatedAt, "galleries.updated_at")) return query } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 06708efe6..0a1d72be5 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -647,6 +647,8 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.handleCriterion(ctx, imageStudioCriterionHandler(qb, imageFilter.Studios)) query.handleCriterion(ctx, imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) query.handleCriterion(ctx, imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite)) + query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.CreatedAt, "images.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.UpdatedAt, "images.updated_at")) return query } diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 388b26947..4cc19c1e6 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -153,6 +153,9 @@ func (qb *movieQueryBuilder) makeFilter(ctx context.Context, movieFilter *models query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url")) query.handleCriterion(ctx, movieStudioCriterionHandler(qb, movieFilter.Studios)) query.handleCriterion(ctx, moviePerformersCriterionHandler(qb, movieFilter.Performers)) + query.handleCriterion(ctx, dateCriterionHandler(movieFilter.Date, "movies.date")) + query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.CreatedAt, "movies.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.UpdatedAt, "movies.updated_at")) return query } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index c0e7ea700..501db6e32 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -541,6 +541,10 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) query.handleCriterion(ctx, performerGalleryCountCriterionHandler(qb, filter.GalleryCount)) + query.handleCriterion(ctx, dateCriterionHandler(filter.Birthdate, tableName+".birthdate")) + query.handleCriterion(ctx, dateCriterionHandler(filter.DeathDate, tableName+".death_date")) + query.handleCriterion(ctx, timestampCriterionHandler(filter.CreatedAt, tableName+".created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(filter.UpdatedAt, tableName+".updated_at")) return query } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index fcdcc0a53..79581c94d 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -877,6 +877,9 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, scenePerformerFavoriteCriterionHandler(sceneFilter.PerformerFavorite)) query.handleCriterion(ctx, scenePerformerAgeCriterionHandler(sceneFilter.PerformerAge)) query.handleCriterion(ctx, scenePhashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable)) + query.handleCriterion(ctx, dateCriterionHandler(sceneFilter.Date, "scenes.date")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneFilter.CreatedAt, "scenes.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneFilter.UpdatedAt, "scenes.updated_at")) return query } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 669ee9a6d..b0ed5ac84 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -131,6 +131,11 @@ func (qb *sceneMarkerQueryBuilder) makeFilter(ctx context.Context, sceneMarkerFi query.handleCriterion(ctx, sceneMarkerTagsCriterionHandler(qb, sceneMarkerFilter.Tags)) query.handleCriterion(ctx, sceneMarkerSceneTagsCriterionHandler(qb, sceneMarkerFilter.SceneTags)) query.handleCriterion(ctx, sceneMarkerPerformersCriterionHandler(qb, sceneMarkerFilter.Performers)) + query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.CreatedAt, "scene_markers.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.UpdatedAt, "scene_markers.updated_at")) + query.handleCriterion(ctx, dateCriterionHandler(sceneMarkerFilter.SceneDate, "scenes.date")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.SceneCreatedAt, "scenes.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.SceneUpdatedAt, "scenes.updated_at")) return query } diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index f060c574b..8611f42fa 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -9,6 +9,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/stashapp/stash/pkg/models" ) @@ -181,6 +182,76 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i panic("unsupported int modifier type " + modifier) } +func getDateCriterionWhereClause(column string, input models.DateCriterionInput) (string, []interface{}) { + return getDateWhereClause(column, input.Modifier, input.Value, input.Value2) +} + +func getDateWhereClause(column string, modifier models.CriterionModifier, value string, upper *string) (string, []interface{}) { + if upper == nil { + u := time.Now().AddDate(0, 0, 1).Format(time.RFC3339) + upper = &u + } + + args := []interface{}{value} + betweenArgs := []interface{}{value, *upper} + + switch modifier { + case models.CriterionModifierIsNull: + return fmt.Sprintf("(%s IS NULL OR %s = '')", column, column), nil + case models.CriterionModifierNotNull: + return fmt.Sprintf("(%s IS NOT NULL AND %s != '')", column, column), nil + case models.CriterionModifierEquals: + return fmt.Sprintf("%s = ?", column), args + case models.CriterionModifierNotEquals: + return fmt.Sprintf("%s != ?", column), args + case models.CriterionModifierBetween: + return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierNotBetween: + return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierLessThan: + return fmt.Sprintf("%s < ?", column), args + case models.CriterionModifierGreaterThan: + return fmt.Sprintf("%s > ?", column), args + } + + panic("unsupported date modifier type") +} + +func getTimestampCriterionWhereClause(column string, input models.TimestampCriterionInput) (string, []interface{}) { + return getTimestampWhereClause(column, input.Modifier, input.Value, input.Value2) +} + +func getTimestampWhereClause(column string, modifier models.CriterionModifier, value string, upper *string) (string, []interface{}) { + if upper == nil { + u := time.Now().AddDate(0, 0, 1).Format(time.RFC3339) + upper = &u + } + + args := []interface{}{value} + betweenArgs := []interface{}{value, *upper} + + switch modifier { + case models.CriterionModifierIsNull: + return fmt.Sprintf("%s IS NULL", column), nil + case models.CriterionModifierNotNull: + return fmt.Sprintf("%s IS NOT NULL", column), nil + case models.CriterionModifierEquals: + return fmt.Sprintf("%s = ?", column), args + case models.CriterionModifierNotEquals: + return fmt.Sprintf("%s != ?", column), args + case models.CriterionModifierBetween: + return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierNotBetween: + return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierLessThan: + return fmt.Sprintf("%s < ?", column), args + case models.CriterionModifierGreaterThan: + return fmt.Sprintf("%s > ?", column), args + } + + panic("unsupported date modifier type") +} + // returns where clause and having clause func getMultiCriterionClause(primaryTable, foreignTable, joinTable, primaryFK, foreignFK string, criterion *models.MultiCriterionInput) (string, string) { whereClause := "" diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index e7b12c9e3..901affc82 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -250,6 +250,8 @@ func (qb *studioQueryBuilder) makeFilter(ctx context.Context, studioFilter *mode query.handleCriterion(ctx, studioGalleryCountCriterionHandler(qb, studioFilter.GalleryCount)) query.handleCriterion(ctx, studioParentCriterionHandler(qb, studioFilter.Parents)) query.handleCriterion(ctx, studioAliasCriterionHandler(qb, studioFilter.Aliases)) + query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.CreatedAt, "studios.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.UpdatedAt, "studios.updated_at")) return query } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index abedc403b..9521e8a79 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -338,6 +338,8 @@ func (qb *tagQueryBuilder) makeFilter(ctx context.Context, tagFilter *models.Tag query.handleCriterion(ctx, tagChildrenCriterionHandler(qb, tagFilter.Children)) query.handleCriterion(ctx, tagParentCountCriterionHandler(qb, tagFilter.ParentCount)) query.handleCriterion(ctx, tagChildCountCriterionHandler(qb, tagFilter.ChildCount)) + query.handleCriterion(ctx, timestampCriterionHandler(tagFilter.CreatedAt, "tags.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(tagFilter.UpdatedAt, "tags.updated_at")) return query } diff --git a/ui/v2.5/src/components/List/AddFilterDialog.tsx b/ui/v2.5/src/components/List/AddFilterDialog.tsx index 183725c61..e4a044ec2 100644 --- a/ui/v2.5/src/components/List/AddFilterDialog.tsx +++ b/ui/v2.5/src/components/List/AddFilterDialog.tsx @@ -9,6 +9,8 @@ import { IHierarchicalLabeledIdCriterion, NumberCriterion, ILabeledIdCriterion, + DateCriterion, + TimestampCriterion, } from "src/models/list-filter/criteria/criterion"; import { NoneCriterion, @@ -20,6 +22,8 @@ import { FormattedMessage, useIntl } from "react-intl"; import { criterionIsHierarchicalLabelValue, criterionIsNumberValue, + criterionIsDateValue, + criterionIsTimestampValue, CriterionType, } from "src/models/list-filter/types"; import { DurationFilter } from "./Filters/DurationFilter"; @@ -28,6 +32,8 @@ import { LabeledIdFilter } from "./Filters/LabeledIdFilter"; import { HierarchicalLabelValueFilter } from "./Filters/HierarchicalLabelValueFilter"; import { OptionsFilter } from "./Filters/OptionsFilter"; import { InputFilter } from "./Filters/InputFilter"; +import { DateFilter } from "./Filters/DateFilter"; +import { TimestampFilter } from "./Filters/TimestampFilter"; import { CountryCriterion } from "src/models/list-filter/criteria/country"; import { CountrySelect } from "../Shared"; @@ -152,6 +158,8 @@ export const AddFilterDialog: React.FC = ({ options && !criterionIsHierarchicalLabelValue(criterion.value) && !criterionIsNumberValue(criterion.value) && + !criterionIsDateValue(criterion.value) && + !criterionIsTimestampValue(criterion.value) && !Array.isArray(criterion.value) ) { defaultValue.current = criterion.value; @@ -170,6 +178,19 @@ export const AddFilterDialog: React.FC = ({ /> ); } + if (criterion instanceof DateCriterion) { + return ( + + ); + } + if (criterion instanceof TimestampCriterion) { + return ( + + ); + } if (criterion instanceof NumberCriterion) { return ( diff --git a/ui/v2.5/src/components/List/Filters/DateFilter.tsx b/ui/v2.5/src/components/List/Filters/DateFilter.tsx new file mode 100644 index 000000000..9235b8f04 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/DateFilter.tsx @@ -0,0 +1,121 @@ +import React, { useRef } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import { CriterionModifier } from "../../../core/generated-graphql"; +import { IDateValue } from "../../../models/list-filter/types"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; + +interface IDateFilterProps { + criterion: Criterion; + onValueChanged: (value: IDateValue) => void; +} + +export const DateFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + + const valueStage = useRef(criterion.value); + + function onChanged( + event: React.ChangeEvent, + property: "value" | "value2" + ) { + const { value } = event.target; + valueStage.current[property] = value; + } + + function onBlurInput() { + onValueChanged(valueStage.current); + } + + let equalsControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.Equals || + criterion.modifier === CriterionModifier.NotEquals + ) { + equalsControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={ + intl.formatMessage({ id: "criterion.value" }) + " (YYYY-MM-DD)" + } + /> + + ); + } + + let lowerControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.GreaterThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + lowerControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={ + intl.formatMessage({ id: "criterion.greater_than" }) + + " (YYYY-MM-DD)" + } + /> + + ); + } + + let upperControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.LessThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + upperControl = ( + + ) => + onChanged( + e, + criterion.modifier === CriterionModifier.LessThan + ? "value" + : "value2" + ) + } + onBlur={onBlurInput} + defaultValue={ + (criterion.modifier === CriterionModifier.LessThan + ? criterion.value?.value + : criterion.value?.value2) ?? "" + } + placeholder={ + intl.formatMessage({ id: "criterion.less_than" }) + " (YYYY-MM-DD)" + } + /> + + ); + } + + return ( + <> + {equalsControl} + {lowerControl} + {upperControl} + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx b/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx new file mode 100644 index 000000000..de6eefb72 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx @@ -0,0 +1,123 @@ +import React, { useRef } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import { CriterionModifier } from "../../../core/generated-graphql"; +import { ITimestampValue } from "../../../models/list-filter/types"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; + +interface ITimestampFilterProps { + criterion: Criterion; + onValueChanged: (value: ITimestampValue) => void; +} + +export const TimestampFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + + const valueStage = useRef(criterion.value); + + function onChanged( + event: React.ChangeEvent, + property: "value" | "value2" + ) { + const { value } = event.target; + valueStage.current[property] = value; + } + + function onBlurInput() { + onValueChanged(valueStage.current); + } + + let equalsControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.Equals || + criterion.modifier === CriterionModifier.NotEquals + ) { + equalsControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={ + intl.formatMessage({ id: "criterion.value" }) + + " (YYYY-MM-DD HH-MM)" + } + /> + + ); + } + + let lowerControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.GreaterThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + lowerControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={ + intl.formatMessage({ id: "criterion.greater_than" }) + + " (YYYY-MM-DD HH-MM)" + } + /> + + ); + } + + let upperControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.LessThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + upperControl = ( + + ) => + onChanged( + e, + criterion.modifier === CriterionModifier.LessThan + ? "value" + : "value2" + ) + } + onBlur={onBlurInput} + defaultValue={ + (criterion.modifier === CriterionModifier.LessThan + ? criterion.value?.value + : criterion.value?.value2) ?? "" + } + placeholder={ + intl.formatMessage({ id: "criterion.less_than" }) + + " (YYYY-MM-DD HH-MM)" + } + /> + + ); + } + + return ( + <> + {equalsControl} + {lowerControl} + {upperControl} + + ); +}; diff --git a/ui/v2.5/src/docs/en/Changelog/v0180.md b/ui/v2.5/src/docs/en/Changelog/v0180.md index 997c1686b..d5c819a8f 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0180.md +++ b/ui/v2.5/src/docs/en/Changelog/v0180.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added filter criteria for Birthdate, Death Date, Date, Created At and Updated At fields. ([#2834](https://github.com/stashapp/stash/pull/2834)) * Support creation of scenes without files. ([#3006](https://github.com/stashapp/stash/pull/3006)) * Added ability to reassign files to other scenes. ([#3006](https://github.com/stashapp/stash/pull/3006)) * Added ability to split and merge scenes. ([#3006](https://github.com/stashapp/stash/pull/3006)) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 3101b17d0..abbbc30e1 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -930,6 +930,9 @@ "release_notes": "Release Notes", "resolution": "Resolution", "scene": "Scene", + "scene_date": "Date of Scene", + "scene_created_at": "Scene Created At", + "scene_updated_at": "Scene Updated At", "sceneTagger": "Scene Tagger", "sceneTags": "Scene Tags", "scene_code": "Studio Code", 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 3fdd843b4..c03fae0cf 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -8,6 +8,8 @@ import { IntCriterionInput, MultiCriterionInput, PHashDuplicationCriterionInput, + DateCriterionInput, + TimestampCriterionInput, } from "src/core/generated-graphql"; import DurationUtils from "src/utils/duration"; import { @@ -17,6 +19,8 @@ import { ILabeledValue, INumberValue, IOptionType, + IDateValue, + ITimestampValue, } from "../types"; export type Option = string | number | IOptionType; @@ -24,7 +28,9 @@ export type CriterionValue = | string | ILabeledId[] | IHierarchicalLabelValue - | INumberValue; + | INumberValue + | IDateValue + | ITimestampValue; const modifierMessageIDs = { [CriterionModifier.Equals]: "criterion_modifier.equals", @@ -501,3 +507,162 @@ export class PhashDuplicateCriterion extends StringCriterion { }; } } + +export class DateCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName?: string, + options?: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.Equals, + CriterionModifier.NotEquals, + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + CriterionModifier.Between, + CriterionModifier.NotBetween, + ], + defaultModifier: CriterionModifier.Equals, + options, + inputType: "text", + }); + } +} + +export function createDateCriterionOption(value: CriterionType) { + return new DateCriterionOption(value, value, value); +} + +export class DateCriterion extends Criterion { + public encodeValue() { + return { + value: this.value.value, + value2: this.value.value2, + }; + } + + protected toCriterionInput(): DateCriterionInput { + return { + modifier: this.modifier, + value: this.value.value, + value2: this.value.value2, + }; + } + + public getLabelValue() { + const { value } = this.value; + return this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween + ? `${value}, ${this.value.value2}` + : `${value}`; + } + + constructor(type: CriterionOption) { + super(type, { value: "", value2: undefined }); + } +} + +export class TimestampCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName?: string, + options?: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + CriterionModifier.Between, + CriterionModifier.NotBetween, + ], + defaultModifier: CriterionModifier.GreaterThan, + options, + inputType: "text", + }); + } +} + +export function createTimestampCriterionOption(value: CriterionType) { + return new TimestampCriterionOption(value, value, value); +} + +export class TimestampCriterion extends Criterion { + public encodeValue() { + return { + value: this.value.value, + value2: this.value.value2, + }; + } + + protected toCriterionInput(): TimestampCriterionInput { + return { + modifier: this.modifier, + value: this.transformValueToInput(this.value.value), + value2: this.value.value2 + ? this.transformValueToInput(this.value.value2) + : null, + }; + } + + public getLabelValue() { + const { value } = this.value; + return this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween + ? `${value}, ${this.value.value2}` + : `${value}`; + } + + private transformValueToInput(value: string): string { + value = value.trim(); + if (/^\d{4}-\d{2}-\d{2}(( |T)\d{2}:\d{2})?$/.test(value)) { + return value.replace(" ", "T"); + } + + return ""; + } + + constructor(type: CriterionOption) { + super(type, { value: "", value2: undefined }); + } +} + +export class MandatoryTimestampCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName?: string, + options?: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, + CriterionModifier.Between, + CriterionModifier.NotBetween, + ], + defaultModifier: CriterionModifier.GreaterThan, + options, + inputType: "text", + }); + } +} + +export function createMandatoryTimestampCriterionOption(value: CriterionType) { + return new MandatoryTimestampCriterionOption(value, value, value); +} diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index e37384683..8f55a8ef0 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -10,6 +10,10 @@ import { ILabeledIdCriterion, BooleanCriterion, BooleanCriterionOption, + DateCriterion, + DateCriterionOption, + TimestampCriterion, + MandatoryTimestampCriterionOption, } from "./criterion"; import { OrganizedCriterion } from "./organized"; import { FavoriteCriterion, PerformerFavoriteCriterion } from "./favorite"; @@ -193,5 +197,17 @@ export function makeCriteria(type: CriterionType = "none") { ); case "ignore_auto_tag": return new BooleanCriterion(new BooleanCriterionOption(type, type)); + case "date": + case "birthdate": + case "death_date": + case "scene_date": + return new DateCriterion(new DateCriterionOption(type, type)); + case "created_at": + case "updated_at": + case "scene_created_at": + case "scene_updated_at": + return new TimestampCriterion( + new MandatoryTimestampCriterionOption(type, type) + ); } } diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index d3b3cd332..8571047f4 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -1,6 +1,8 @@ import { createMandatoryNumberCriterionOption, createStringCriterionOption, + createDateCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { PerformerFavoriteCriterionOption } from "./criteria/favorite"; import { GalleryIsMissingCriterionOption } from "./criteria/is-missing"; @@ -61,6 +63,9 @@ const criterionOptions = [ StudiosCriterionOption, createStringCriterionOption("url"), createMandatoryNumberCriterionOption("file_count", "zip_file_count"), + createDateCriterionOption("date"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const GalleryListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 5102e72c5..07b12d0f4 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -2,6 +2,7 @@ import { createMandatoryNumberCriterionOption, createMandatoryStringCriterionOption, createStringCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { PerformerFavoriteCriterionOption } from "./criteria/favorite"; import { ImageIsMissingCriterionOption } from "./criteria/is-missing"; @@ -45,6 +46,8 @@ const criterionOptions = [ PerformerFavoriteCriterionOption, StudiosCriterionOption, createMandatoryNumberCriterionOption("file_count"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const ImageListFilterOptions = new ListFilterOptions( defaultSortBy, diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index dcf495f76..3f72d5239 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -1,6 +1,8 @@ import { createMandatoryNumberCriterionOption, createStringCriterionOption, + createDateCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { MovieIsMissingCriterionOption } from "./criteria/is-missing"; import { RatingCriterionOption } from "./criteria/rating"; @@ -30,6 +32,9 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("duration"), RatingCriterionOption, PerformersCriterionOption, + createDateCriterionOption("date"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const MovieListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index c2836544a..e6e9c190c 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -3,6 +3,8 @@ import { createMandatoryNumberCriterionOption, createStringCriterionOption, createBooleanCriterionOption, + createDateCriterionOption, + createMandatoryTimestampCriterionOption, NumberCriterionOption, } from "./criteria/criterion"; import { FavoriteCriterionOption } from "./criteria/favorite"; @@ -84,6 +86,10 @@ const criterionOptions = [ new NumberCriterionOption("height", "height_cm", "height_cm"), ...numberCriteria.map((c) => createNumberCriterionOption(c)), ...stringCriteria.map((c) => createStringCriterionOption(c)), + createDateCriterionOption("birthdate"), + createDateCriterionOption("death_date"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const PerformerListFilterOptions = new ListFilterOptions( defaultSortBy, diff --git a/ui/v2.5/src/models/list-filter/scene-markers.ts b/ui/v2.5/src/models/list-filter/scene-markers.ts index 0080e017e..3de42b2a1 100644 --- a/ui/v2.5/src/models/list-filter/scene-markers.ts +++ b/ui/v2.5/src/models/list-filter/scene-markers.ts @@ -2,6 +2,10 @@ import { PerformersCriterionOption } from "./criteria/performers"; import { SceneTagsCriterionOption, TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; +import { + createDateCriterionOption, + createMandatoryTimestampCriterionOption, +} from "./criteria/criterion"; const defaultSortBy = "title"; const sortByOptions = [ @@ -16,6 +20,11 @@ const criterionOptions = [ TagsCriterionOption, SceneTagsCriterionOption, PerformersCriterionOption, + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), + createDateCriterionOption("scene_date"), + createMandatoryTimestampCriterionOption("scene_created_at"), + createMandatoryTimestampCriterionOption("scene_updated_at"), ]; export const SceneMarkerListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 73b0185ee..a8a3b93a0 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -2,6 +2,8 @@ import { createMandatoryNumberCriterionOption, createMandatoryStringCriterionOption, createStringCriterionOption, + createDateCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { HasMarkersCriterionOption } from "./criteria/has-markers"; import { SceneIsMissingCriterionOption } from "./criteria/is-missing"; @@ -85,6 +87,9 @@ const criterionOptions = [ CaptionsCriterionOption, createMandatoryNumberCriterionOption("interactive_speed"), createMandatoryNumberCriterionOption("file_count"), + createDateCriterionOption("date"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const SceneListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index f693572c1..32a7e605d 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -3,6 +3,7 @@ import { createMandatoryNumberCriterionOption, createMandatoryStringCriterionOption, createStringCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { StudioIsMissingCriterionOption } from "./criteria/is-missing"; import { RatingCriterionOption } from "./criteria/rating"; @@ -42,6 +43,8 @@ const criterionOptions = [ createStringCriterionOption("url"), createStringCriterionOption("stash_id"), createStringCriterionOption("aliases"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const StudioListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 468627a77..8e90a27e7 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -4,6 +4,7 @@ import { createMandatoryStringCriterionOption, createStringCriterionOption, MandatoryNumberCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { TagIsMissingCriterionOption } from "./criteria/is-missing"; import { ListFilterOptions } from "./filter-options"; @@ -63,6 +64,8 @@ const criterionOptions = [ "child_tag_count", "child_count" ), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const TagListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 89dec60b1..e230a3ba9 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -33,6 +33,16 @@ export interface IPHashDuplicationValue { distance?: number; // currently not implemented } +export interface IDateValue { + value: string; + value2: string | undefined; +} + +export interface ITimestampValue { + value: string; + value2: string | undefined; +} + export function criterionIsHierarchicalLabelValue( // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any @@ -47,6 +57,20 @@ export function criterionIsNumberValue( return typeof value === "object" && "value" in value && "value2" in value; } +export function criterionIsDateValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any +): value is IDateValue { + return typeof value === "object" && "value" in value && "value2" in value; +} + +export function criterionIsTimestampValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any +): value is ITimestampValue { + return typeof value === "object" && "value" in value && "value2" in value; +} + export interface IOptionType { id: string; name?: string; @@ -126,5 +150,13 @@ export type CriterionType = | "duplicated" | "ignore_auto_tag" | "file_count" + | "date" + | "created_at" + | "updated_at" + | "birthdate" + | "death_date" + | "scene_date" + | "scene_created_at" + | "scene_updated_at" | "description" | "scene_code";