mirror of
https://github.com/stashapp/stash.git
synced 2026-04-15 19:40:56 +02:00
Merge branch 'stashapp:develop' into frames
This commit is contained in:
commit
7f9fde313b
159 changed files with 7198 additions and 4048 deletions
|
|
@ -157,7 +157,7 @@ input MoveFilesInput {
|
|||
|
||||
input SetFingerprintsInput {
|
||||
type: String!
|
||||
"an null value will remove the fingerprint"
|
||||
"a null value will remove the fingerprint"
|
||||
value: String
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -152,15 +152,15 @@ input PerformerFilterType {
|
|||
fake_tits: StringCriterionInput
|
||||
"Filter by penis length value"
|
||||
penis_length: FloatCriterionInput
|
||||
"Filter by ciricumcision"
|
||||
"Filter by circumcision"
|
||||
circumcised: CircumcisionCriterionInput
|
||||
"Deprecated: use career_start and career_end. This filter is non-functional."
|
||||
career_length: StringCriterionInput
|
||||
@deprecated(reason: "Use career_start and career_end")
|
||||
"Filter by career start year"
|
||||
career_start: IntCriterionInput
|
||||
"Filter by career end year"
|
||||
career_end: IntCriterionInput
|
||||
"Filter by career start"
|
||||
career_start: DateCriterionInput
|
||||
"Filter by career end"
|
||||
career_end: DateCriterionInput
|
||||
"Filter by tattoos"
|
||||
tattoos: StringCriterionInput
|
||||
"Filter by piercings"
|
||||
|
|
@ -249,9 +249,9 @@ input SceneMarkerFilterType {
|
|||
updated_at: TimestampCriterionInput
|
||||
"Filter by scene date"
|
||||
scene_date: DateCriterionInput
|
||||
"Filter by cscene reation time"
|
||||
"Filter by scene creation time"
|
||||
scene_created_at: TimestampCriterionInput
|
||||
"Filter by lscene ast update time"
|
||||
"Filter by scene last update time"
|
||||
scene_updated_at: TimestampCriterionInput
|
||||
"Filter by related scenes that meet this criteria"
|
||||
scene_filter: SceneFilterType
|
||||
|
|
@ -665,7 +665,7 @@ input TagFilterType {
|
|||
"Filter by number of parent tags the tag has"
|
||||
parent_count: IntCriterionInput
|
||||
|
||||
"Filter by number f child tags the tag has"
|
||||
"Filter by number of child tags the tag has"
|
||||
child_count: IntCriterionInput
|
||||
|
||||
"Filter by autotag ignore value"
|
||||
|
|
@ -933,7 +933,7 @@ input GenderCriterionInput {
|
|||
}
|
||||
|
||||
input CircumcisionCriterionInput {
|
||||
value: [CircumisedEnum!]
|
||||
value: [CircumcisedEnum!]
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ input BulkGroupUpdateInput {
|
|||
ids: [ID!]
|
||||
# rating expressed as 1-100
|
||||
rating100: Int
|
||||
date: String
|
||||
synopsis: String
|
||||
studio_id: ID
|
||||
director: String
|
||||
urls: BulkUpdateStrings
|
||||
|
|
|
|||
|
|
@ -131,6 +131,14 @@ type ScanMetadataOptions {
|
|||
input CleanMetadataInput {
|
||||
paths: [String!]
|
||||
|
||||
"""
|
||||
Don't check zip file contents when determining whether to clean a file.
|
||||
This can significantly speed up the clean process, but will potentially miss removed files within zip files.
|
||||
Where users do not modify zip files contents directly, this should be safe to use.
|
||||
Defaults to false.
|
||||
"""
|
||||
ignoreZipFileContents: Boolean
|
||||
|
||||
"Do a dry run. Don't delete any files"
|
||||
dryRun: Boolean!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ enum GenderEnum {
|
|||
NON_BINARY
|
||||
}
|
||||
|
||||
enum CircumisedEnum {
|
||||
enum CircumcisedEnum {
|
||||
CUT
|
||||
UNCUT
|
||||
}
|
||||
|
|
@ -29,10 +29,10 @@ type Performer {
|
|||
measurements: String
|
||||
fake_tits: String
|
||||
penis_length: Float
|
||||
circumcised: CircumisedEnum
|
||||
circumcised: CircumcisedEnum
|
||||
career_length: String @deprecated(reason: "Use career_start and career_end")
|
||||
career_start: Int
|
||||
career_end: Int
|
||||
career_start: String
|
||||
career_end: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
alias_list: [String!]!
|
||||
|
|
@ -78,10 +78,10 @@ input PerformerCreateInput {
|
|||
measurements: String
|
||||
fake_tits: String
|
||||
penis_length: Float
|
||||
circumcised: CircumisedEnum
|
||||
circumcised: CircumcisedEnum
|
||||
career_length: String @deprecated(reason: "Use career_start and career_end")
|
||||
career_start: Int
|
||||
career_end: Int
|
||||
career_start: String
|
||||
career_end: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
|
|
@ -119,10 +119,10 @@ input PerformerUpdateInput {
|
|||
measurements: String
|
||||
fake_tits: String
|
||||
penis_length: Float
|
||||
circumcised: CircumisedEnum
|
||||
circumcised: CircumcisedEnum
|
||||
career_length: String @deprecated(reason: "Use career_start and career_end")
|
||||
career_start: Int
|
||||
career_end: Int
|
||||
career_start: String
|
||||
career_end: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
|
||||
|
|
@ -165,10 +165,10 @@ input BulkPerformerUpdateInput {
|
|||
measurements: String
|
||||
fake_tits: String
|
||||
penis_length: Float
|
||||
circumcised: CircumisedEnum
|
||||
circumcised: CircumcisedEnum
|
||||
career_length: String @deprecated(reason: "Use career_start and career_end")
|
||||
career_start: Int
|
||||
career_end: Int
|
||||
career_start: String
|
||||
career_end: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ type ScrapedPerformer {
|
|||
penis_length: String
|
||||
circumcised: String
|
||||
career_length: String @deprecated(reason: "Use career_start and career_end")
|
||||
career_start: Int
|
||||
career_end: Int
|
||||
career_start: String
|
||||
career_end: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
# aliases must be comma-delimited to be parsed correctly
|
||||
|
|
@ -57,8 +57,8 @@ input ScrapedPerformerInput {
|
|||
penis_length: String
|
||||
circumcised: String
|
||||
career_length: String @deprecated(reason: "Use career_start and career_end")
|
||||
career_start: Int
|
||||
career_end: Int
|
||||
career_start: String
|
||||
career_end: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
aliases: String
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ type ScrapedTag {
|
|||
name: String!
|
||||
description: String
|
||||
alias_list: [String!]
|
||||
parent: ScrapedTag
|
||||
"Remote site ID, if applicable"
|
||||
remote_site_id: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"github.com/stashapp/stash/pkg/image"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) {
|
||||
|
|
@ -110,12 +109,28 @@ func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer)
|
|||
return obj.Height, nil
|
||||
}
|
||||
|
||||
func (r *performerResolver) CareerStart(ctx context.Context, obj *models.Performer) (*string, error) {
|
||||
if obj.CareerStart != nil {
|
||||
ret := obj.CareerStart.String()
|
||||
return &ret, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *performerResolver) CareerEnd(ctx context.Context, obj *models.Performer) (*string, error) {
|
||||
if obj.CareerEnd != nil {
|
||||
ret := obj.CareerEnd.String()
|
||||
return &ret, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) {
|
||||
if obj.CareerStart == nil && obj.CareerEnd == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ret := utils.FormatYearRange(obj.CareerStart, obj.CareerEnd)
|
||||
ret := models.FormatYearRange(obj.CareerStart, obj.CareerEnd)
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,6 +227,12 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp
|
|||
func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) {
|
||||
updatedGroup := models.NewGroupPartial()
|
||||
|
||||
updatedGroup.Date, err = translator.optionalDate(input.Date, "date")
|
||||
if err != nil {
|
||||
err = fmt.Errorf("converting date: %w", err)
|
||||
return
|
||||
}
|
||||
updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis")
|
||||
updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100")
|
||||
updatedGroup.Director = translator.optionalString(input.Director, "director")
|
||||
|
||||
|
|
|
|||
|
|
@ -52,17 +52,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
|||
newPerformer.FakeTits = translator.string(input.FakeTits)
|
||||
newPerformer.PenisLength = input.PenisLength
|
||||
newPerformer.Circumcised = input.Circumcised
|
||||
newPerformer.CareerStart = input.CareerStart
|
||||
newPerformer.CareerEnd = input.CareerEnd
|
||||
// if career_start/career_end not provided, parse deprecated career_length
|
||||
if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil {
|
||||
start, end, err := utils.ParseYearRangeString(*input.CareerLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
|
||||
}
|
||||
newPerformer.CareerStart = start
|
||||
newPerformer.CareerEnd = end
|
||||
}
|
||||
newPerformer.Tattoos = translator.string(input.Tattoos)
|
||||
newPerformer.Piercings = translator.string(input.Piercings)
|
||||
newPerformer.Favorite = translator.bool(input.Favorite)
|
||||
|
|
@ -100,6 +89,25 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
|||
return nil, fmt.Errorf("converting death date: %w", err)
|
||||
}
|
||||
|
||||
newPerformer.CareerStart, err = translator.datePtr(input.CareerStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting career start: %w", err)
|
||||
}
|
||||
newPerformer.CareerEnd, err = translator.datePtr(input.CareerEnd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting career end: %w", err)
|
||||
}
|
||||
|
||||
// if career_start/career_end not provided, parse deprecated career_length
|
||||
if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil {
|
||||
start, end, err := models.ParseYearRangeString(*input.CareerLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
|
||||
}
|
||||
newPerformer.CareerStart = start
|
||||
newPerformer.CareerEnd = end
|
||||
}
|
||||
|
||||
newPerformer.TagIDs, err = translator.relatedIds(input.TagIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
|
|
@ -273,18 +281,25 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
|
|||
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
|
||||
// prefer career_start/career_end over deprecated career_length
|
||||
if translator.hasField("career_start") || translator.hasField("career_end") {
|
||||
updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start")
|
||||
updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end")
|
||||
var err error
|
||||
updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting career start: %w", err)
|
||||
}
|
||||
updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting career end: %w", err)
|
||||
}
|
||||
} else if translator.hasField("career_length") && input.CareerLength != nil {
|
||||
start, end, err := utils.ParseYearRangeString(*input.CareerLength)
|
||||
start, end, err := models.ParseYearRangeString(*input.CareerLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
|
||||
}
|
||||
if start != nil {
|
||||
updatedPerformer.CareerStart = models.NewOptionalInt(*start)
|
||||
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
|
||||
}
|
||||
if end != nil {
|
||||
updatedPerformer.CareerEnd = models.NewOptionalInt(*end)
|
||||
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
|
||||
}
|
||||
}
|
||||
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
|
||||
|
|
@ -444,18 +459,24 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
|||
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
|
||||
// prefer career_start/career_end over deprecated career_length
|
||||
if translator.hasField("career_start") || translator.hasField("career_end") {
|
||||
updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start")
|
||||
updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end")
|
||||
updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting career start: %w", err)
|
||||
}
|
||||
updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting career end: %w", err)
|
||||
}
|
||||
} else if translator.hasField("career_length") && input.CareerLength != nil {
|
||||
start, end, err := utils.ParseYearRangeString(*input.CareerLength)
|
||||
start, end, err := models.ParseYearRangeString(*input.CareerLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
|
||||
}
|
||||
if start != nil {
|
||||
updatedPerformer.CareerStart = models.NewOptionalInt(*start)
|
||||
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
|
||||
}
|
||||
if end != nil {
|
||||
updatedPerformer.CareerEnd = models.NewOptionalInt(*end)
|
||||
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
|
||||
}
|
||||
}
|
||||
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
|
||||
|
|
|
|||
|
|
@ -314,6 +314,8 @@ type CleanMetadataInput struct {
|
|||
Paths []string `json:"paths"`
|
||||
// Do a dry run. Don't delete any files
|
||||
DryRun bool `json:"dryRun"`
|
||||
|
||||
IgnoreZipFileContents bool `json:"ignoreZipFileContents"`
|
||||
}
|
||||
|
||||
func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {
|
||||
|
|
@ -431,7 +433,7 @@ type StashBoxBatchTagInput struct {
|
|||
ExcludeFields []string `json:"exclude_fields"`
|
||||
// Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false
|
||||
Refresh bool `json:"refresh"`
|
||||
// If batch adding studios, should their parent studios also be created?
|
||||
// If batch adding studios or tags, should their parent entities also be created?
|
||||
CreateParent bool `json:"createParent"`
|
||||
// IDs in stash of the items to update.
|
||||
// If set, names and stash_ids fields will be ignored.
|
||||
|
|
@ -749,6 +751,7 @@ func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagI
|
|||
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
tag: t,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
|
|
@ -769,6 +772,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
|
|||
if len(stashID) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
stashID: &stashID,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
|
|
@ -780,6 +784,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
|
|||
if len(name) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
name: &name,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
|
|
@ -806,6 +811,7 @@ func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInp
|
|||
for _, t := range tags {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
tag: t,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ type stashIgnorePathFilter struct {
|
|||
libraryRoot string
|
||||
}
|
||||
|
||||
func (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool {
|
||||
return f.filter.Accept(ctx, path, info, f.libraryRoot)
|
||||
func (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
|
||||
return f.filter.Accept(ctx, path, info, f.libraryRoot, zipFilePath)
|
||||
}
|
||||
|
||||
// createTestFileOnDisk creates a file with some content.
|
||||
|
|
@ -105,7 +105,7 @@ temp/
|
|||
if err != nil {
|
||||
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
|
||||
}
|
||||
accepted := scanner.AcceptEntry(ctx, scenario.path, info)
|
||||
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
|
||||
|
||||
if accepted != scenario.accepted {
|
||||
t.Errorf("unexpected accept result for %s: expected %v, got %v",
|
||||
|
|
@ -160,7 +160,7 @@ func TestScannerWithNestedStashIgnore(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
|
||||
}
|
||||
accepted := scanner.AcceptEntry(ctx, scenario.path, info)
|
||||
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
|
||||
|
||||
if accepted != scenario.accepted {
|
||||
t.Errorf("unexpected accept result for %s: expected %v, got %v",
|
||||
|
|
@ -205,7 +205,7 @@ func TestScannerWithoutStashIgnore(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
|
||||
}
|
||||
accepted := scanner.AcceptEntry(ctx, scenario.path, info)
|
||||
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
|
||||
|
||||
if accepted != scenario.accepted {
|
||||
t.Errorf("unexpected accept result for %s: expected %v, got %v",
|
||||
|
|
@ -258,7 +258,7 @@ func TestScannerWithNegationPattern(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
|
||||
}
|
||||
accepted := scanner.AcceptEntry(ctx, scenario.path, info)
|
||||
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
|
||||
|
||||
if accepted != scenario.accepted {
|
||||
t.Errorf("unexpected accept result for %s: expected %v, got %v",
|
||||
|
|
|
|||
|
|
@ -40,9 +40,10 @@ func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) error {
|
|||
}
|
||||
|
||||
j.cleaner.Clean(ctx, file.CleanOptions{
|
||||
Paths: j.input.Paths,
|
||||
DryRun: j.input.DryRun,
|
||||
PathFilter: newCleanFilter(instance.Config),
|
||||
Paths: j.input.Paths,
|
||||
DryRun: j.input.DryRun,
|
||||
IgnoreZipFileContents: j.input.IgnoreZipFileContents,
|
||||
PathFilter: newCleanFilter(instance.Config),
|
||||
}, progress)
|
||||
|
||||
if job.IsCancelled(ctx) {
|
||||
|
|
@ -159,7 +160,7 @@ func newCleanFilter(c *config.Config) *cleanFilter {
|
|||
}
|
||||
}
|
||||
|
||||
func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool {
|
||||
func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
|
||||
// #1102 - clean anything in generated path
|
||||
generatedPath := f.generatedPath
|
||||
|
||||
|
|
@ -184,7 +185,7 @@ func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
|
|||
}
|
||||
|
||||
// Check .stashignore files, bounded to the library root.
|
||||
if !f.stashIgnoreFilter.Accept(ctx, path, info, stash.Path) {
|
||||
if !f.stashIgnoreFilter.Accept(ctx, path, info, stash.Path, zipFilePath) {
|
||||
logger.Infof("%s is excluded due to .stashignore. Marking to clean: %q", fileOrFolder, path)
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,12 @@ func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file.
|
|||
return nil
|
||||
}
|
||||
|
||||
if !j.scanner.AcceptEntry(ctx, path, info) {
|
||||
zipFilePath := ""
|
||||
if zipFile != nil {
|
||||
zipFilePath = zipFile.Path
|
||||
}
|
||||
|
||||
if !j.scanner.AcceptEntry(ctx, path, info, zipFilePath) {
|
||||
if info.IsDir() {
|
||||
logger.Debugf("Skipping directory %s", path)
|
||||
return fs.SkipDir
|
||||
|
|
@ -565,7 +570,7 @@ func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Tim
|
|||
}
|
||||
}
|
||||
|
||||
func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool {
|
||||
func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
|
||||
if fsutil.IsPathInDir(f.generatedPath, path) {
|
||||
logger.Warnf("Skipping %q as it overlaps with the generated folder", path)
|
||||
return false
|
||||
|
|
@ -583,7 +588,7 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
|
|||
}
|
||||
|
||||
// Check .stashignore files, bounded to the library root.
|
||||
if !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path) {
|
||||
if !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path, zipFilePath) {
|
||||
logger.Debugf("Skipping %s due to .stashignore", path)
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -541,6 +541,7 @@ type stashBoxBatchTagTagTask struct {
|
|||
name *string
|
||||
stashID *string
|
||||
tag *models.Tag
|
||||
createParent bool
|
||||
excludedFields []string
|
||||
}
|
||||
|
||||
|
|
@ -630,7 +631,7 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
|
|||
result := results[0]
|
||||
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint)
|
||||
return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -638,6 +639,39 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error {
|
||||
if parent.StoredID == nil {
|
||||
// Create new parent tag
|
||||
newParentTag := parent.ToTag(t.box.Endpoint, excluded)
|
||||
|
||||
r := instance.Repository
|
||||
err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Tag
|
||||
|
||||
if err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storedID := strconv.Itoa(newParentTag.ID)
|
||||
parent.StoredID = &storedID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create parent tag %s: %v", parent.Name, err)
|
||||
} else {
|
||||
logger.Infof("Created parent tag %s", parent.Name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Parent already exists — nothing to update for categories
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) {
|
||||
// Determine the tag ID to update — either from the task's tag or from the
|
||||
// StoredID set by match.ScrapedTag (when batch adding by name and the tag
|
||||
|
|
@ -649,6 +683,12 @@ func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *mode
|
|||
tagID, _ = strconv.Atoi(*s.StoredID)
|
||||
}
|
||||
|
||||
if s.Parent != nil && t.createParent {
|
||||
if err := t.processParentTag(ctx, s.Parent, excluded); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if tagID > 0 {
|
||||
r := instance.Repository
|
||||
err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ type cleanJob struct {
|
|||
type CleanOptions struct {
|
||||
Paths []string
|
||||
|
||||
// IgnoreZipFileContents will skip checking the contents of zip files when determining whether to clean a file.
|
||||
// This can significantly speed up the clean process, but will potentially miss removed files within zip files.
|
||||
// Where users do not modify zip files contents directly, this should be safe to use.
|
||||
IgnoreZipFileContents bool
|
||||
|
||||
// Do a dry run. Don't delete any files
|
||||
DryRun bool
|
||||
|
||||
|
|
@ -174,13 +179,16 @@ func (j *cleanJob) assessFiles(ctx context.Context, toDelete *deleteSet) error {
|
|||
|
||||
more := true
|
||||
r := j.Repository
|
||||
|
||||
includeZipContents := !j.options.IgnoreZipFileContents
|
||||
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
for more {
|
||||
if job.IsCancelled(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
files, err := r.File.FindAllInPaths(ctx, j.options.Paths, batchSize, offset)
|
||||
files, err := r.File.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error querying for files: %w", err)
|
||||
}
|
||||
|
|
@ -258,6 +266,8 @@ func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error
|
|||
offset := 0
|
||||
progress := j.progress
|
||||
|
||||
includeZipContents := !j.options.IgnoreZipFileContents
|
||||
|
||||
more := true
|
||||
r := j.Repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
|
|
@ -266,7 +276,7 @@ func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error
|
|||
return nil
|
||||
}
|
||||
|
||||
folders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, batchSize, offset)
|
||||
folders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error querying for folders: %w", err)
|
||||
}
|
||||
|
|
@ -348,8 +358,14 @@ func (j *cleanJob) shouldClean(ctx context.Context, f models.File) bool {
|
|||
// run through path filter, if returns false then the file should be cleaned
|
||||
filter := j.options.PathFilter
|
||||
|
||||
// need to get the zip file path if present
|
||||
zipFilePath := ""
|
||||
if f.Base().ZipFile != nil {
|
||||
zipFilePath = f.Base().ZipFile.Base().Path
|
||||
}
|
||||
|
||||
// don't log anything - assume filter will have logged the reason
|
||||
return !filter.Accept(ctx, path, info)
|
||||
return !filter.Accept(ctx, path, info, zipFilePath)
|
||||
}
|
||||
|
||||
func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool {
|
||||
|
|
@ -387,8 +403,14 @@ func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool
|
|||
// run through path filter, if returns false then the file should be cleaned
|
||||
filter := j.options.PathFilter
|
||||
|
||||
// need to get the zip file path if present
|
||||
zipFilePath := ""
|
||||
if f.ZipFile != nil {
|
||||
zipFilePath = f.ZipFile.Base().Path
|
||||
}
|
||||
|
||||
// don't log anything - assume filter will have logged the reason
|
||||
return !filter.Accept(ctx, path, info)
|
||||
return !filter.Accept(ctx, path, info, zipFilePath)
|
||||
}
|
||||
|
||||
func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package file
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
|
|
@ -88,6 +87,11 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
|
|||
|
||||
r := s.Repository
|
||||
|
||||
zipFilePath := ""
|
||||
if file.ZipFile != nil {
|
||||
zipFilePath = file.ZipFile.Base().Path
|
||||
}
|
||||
|
||||
if err := SymWalk(file.FS, file.Path, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// don't let errors prevent scanning
|
||||
|
|
@ -111,7 +115,7 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
|
|||
return nil
|
||||
}
|
||||
|
||||
if !s.AcceptEntry(ctx, path, info) {
|
||||
if !s.AcceptEntry(ctx, path, info, zipFilePath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -161,9 +165,7 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
|
|||
continue
|
||||
}
|
||||
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("checking for parent folder %q: %w", pf.Path, err)
|
||||
}
|
||||
// treat any error as missing folder
|
||||
|
||||
// parent folder is missing, possible candidate
|
||||
// count the total number of files in the existing folder
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
// PathFilter provides a filter function for paths.
|
||||
type PathFilter interface {
|
||||
Accept(ctx context.Context, path string, info fs.FileInfo) bool
|
||||
Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool
|
||||
}
|
||||
|
||||
type PathFilterFunc func(path string) bool
|
||||
|
|
|
|||
|
|
@ -111,12 +111,12 @@ type ScannedFile struct {
|
|||
}
|
||||
|
||||
// AcceptEntry determines if the file entry should be accepted for scanning
|
||||
func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo) bool {
|
||||
func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
|
||||
// always accept if there's no filters
|
||||
accept := len(s.ScanFilters) == 0
|
||||
for _, filter := range s.ScanFilters {
|
||||
// accept if any filter accepts the file
|
||||
if filter.Accept(ctx, path, info) {
|
||||
if filter.Accept(ctx, path, info, zipFilePath) {
|
||||
accept = true
|
||||
break
|
||||
}
|
||||
|
|
@ -462,7 +462,11 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
|
|||
|
||||
// determine if the file is renamed from an existing file in the store
|
||||
// do this after decoration so that missing fields can be populated
|
||||
renamed, err := s.handleRename(ctx, file, fp)
|
||||
zipFilePath := ""
|
||||
if f.ZipFile != nil {
|
||||
zipFilePath = f.ZipFile.Base().Path
|
||||
}
|
||||
renamed, err := s.handleRename(ctx, file, fp, zipFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -572,7 +576,7 @@ func (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) {
|
|||
return fs.OpenZip(zipPath, zipSize)
|
||||
}
|
||||
|
||||
func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {
|
||||
func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint, zipFilePath string) (models.File, error) {
|
||||
var others []models.File
|
||||
|
||||
for _, tfp := range fp {
|
||||
|
|
@ -614,7 +618,7 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
|
|||
// treat as a move
|
||||
missing = append(missing, other)
|
||||
}
|
||||
case !s.AcceptEntry(ctx, other.Base().Path, info):
|
||||
case !s.AcceptEntry(ctx, other.Base().Path, info, zipFilePath):
|
||||
// #4393 - if the file is no longer in the configured library paths, treat it as a move
|
||||
logger.Debugf("File %q no longer in library paths. Treating as a move.", other.Base().Path)
|
||||
missing = append(missing, other)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ func NewStashIgnoreFilter() *StashIgnoreFilter {
|
|||
// applies gitignore-style pattern matching.
|
||||
// The libraryRoot parameter bounds the search for .stashignore files -
|
||||
// only directories within the library root are checked.
|
||||
func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string) bool {
|
||||
// zipFilepath is the path of the zip file if the file is inside a zip.
|
||||
// .stashignore files will not be read within zip files.
|
||||
func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string, zipFilePath string) bool {
|
||||
// If no library root provided, accept the file (safety fallback).
|
||||
if libraryRoot == "" {
|
||||
return true
|
||||
|
|
@ -62,6 +64,11 @@ func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.Fil
|
|||
// Get the directory containing this path.
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
// If the file is inside a zip, use the zip file's directory as the base for .stashignore lookup.
|
||||
if zipFilePath != "" {
|
||||
dir = filepath.Dir(zipFilePath)
|
||||
}
|
||||
|
||||
// Collect all applicable ignore entries from library root to this directory.
|
||||
entries := f.collectIgnoreEntries(dir, libraryRoot)
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func walkAndFilter(t *testing.T, root string, filter *StashIgnoreFilter) []strin
|
|||
return err
|
||||
}
|
||||
|
||||
if filter.Accept(ctx, path, info, root) {
|
||||
if filter.Accept(ctx, path, info, root, "") {
|
||||
relPath, _ := filepath.Rel(root, path)
|
||||
accepted = append(accepted, relPath)
|
||||
} else if info.IsDir() {
|
||||
|
|
|
|||
|
|
@ -188,6 +188,20 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
|
|||
return
|
||||
}
|
||||
|
||||
// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.
|
||||
func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.Parent == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match parent by name only (categories don't have StashDB tag IDs)
|
||||
return ScrapedTag(ctx, qb, s.Parent, "")
|
||||
}
|
||||
|
||||
// ScrapedTag matches the provided tag with the tags
|
||||
// in the database and sets the ID field if one is found.
|
||||
func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package models
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
|
|
@ -61,3 +62,114 @@ func ParseDate(s string) (Date, error) {
|
|||
|
||||
return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs)
|
||||
}
|
||||
|
||||
func DateFromYear(year int) Date {
|
||||
return Date{
|
||||
Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
Precision: DatePrecisionYear,
|
||||
}
|
||||
}
|
||||
|
||||
func FormatYearRange(start *Date, end *Date) string {
|
||||
var (
|
||||
startStr, endStr string
|
||||
)
|
||||
|
||||
if start != nil {
|
||||
startStr = start.Format(dateFormatPrecision[DatePrecisionYear])
|
||||
}
|
||||
|
||||
if end != nil {
|
||||
endStr = end.Format(dateFormatPrecision[DatePrecisionYear])
|
||||
}
|
||||
|
||||
switch {
|
||||
case startStr == "" && endStr == "":
|
||||
return ""
|
||||
case endStr == "":
|
||||
return fmt.Sprintf("%s -", startStr)
|
||||
case startStr == "":
|
||||
return fmt.Sprintf("- %s", endStr)
|
||||
default:
|
||||
return fmt.Sprintf("%s - %s", startStr, endStr)
|
||||
}
|
||||
}
|
||||
|
||||
func FormatYearRangeString(start *string, end *string) string {
|
||||
switch {
|
||||
case start == nil && end == nil:
|
||||
return ""
|
||||
case end == nil:
|
||||
return fmt.Sprintf("%s -", *start)
|
||||
case start == nil:
|
||||
return fmt.Sprintf("- %s", *end)
|
||||
default:
|
||||
return fmt.Sprintf("%s - %s", *start, *end)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseYearRangeString parses a year range string into start and end year integers.
|
||||
// Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present".
|
||||
// Returns nil for start/end if not present in the string.
|
||||
func ParseYearRangeString(s string) (start *Date, end *Date, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, nil, fmt.Errorf("empty year range string")
|
||||
}
|
||||
|
||||
// normalize "present" to empty end
|
||||
lower := strings.ToLower(s)
|
||||
lower = strings.ReplaceAll(lower, "present", "")
|
||||
|
||||
// split on "-" if it contains one
|
||||
var parts []string
|
||||
if strings.Contains(lower, "-") {
|
||||
parts = strings.SplitN(lower, "-", 2)
|
||||
} else {
|
||||
// single value, treat as start year
|
||||
year, err := parseYear(lower)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err)
|
||||
}
|
||||
return year, nil, nil
|
||||
}
|
||||
|
||||
startStr := strings.TrimSpace(parts[0])
|
||||
endStr := strings.TrimSpace(parts[1])
|
||||
|
||||
if startStr != "" {
|
||||
y, err := parseYear(startStr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err)
|
||||
}
|
||||
start = y
|
||||
}
|
||||
|
||||
if endStr != "" {
|
||||
y, err := parseYear(endStr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err)
|
||||
}
|
||||
end = y
|
||||
}
|
||||
|
||||
if start == nil && end == nil {
|
||||
return nil, nil, fmt.Errorf("could not parse year range %q", s)
|
||||
}
|
||||
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func parseYear(s string) (*Date, error) {
|
||||
ret, err := ParseDate(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing year %q: %w", s, err)
|
||||
}
|
||||
|
||||
year := ret.Time.Year()
|
||||
if year < 1900 || year > 2200 {
|
||||
return nil, fmt.Errorf("year %d out of reasonable range", year)
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package models
|
|||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseDateStringAsTime(t *testing.T) {
|
||||
|
|
@ -48,3 +50,102 @@ func TestParseDateStringAsTime(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatYearRange(t *testing.T) {
|
||||
datePtr := func(v int) *Date {
|
||||
date := DateFromYear(v)
|
||||
return &date
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start *Date
|
||||
end *Date
|
||||
want string
|
||||
}{
|
||||
{"both nil", nil, nil, ""},
|
||||
{"only start", datePtr(2005), nil, "2005 -"},
|
||||
{"only end", nil, datePtr(2010), "- 2010"},
|
||||
{"start and end", datePtr(2005), datePtr(2010), "2005 - 2010"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatYearRange(tt.start, tt.end)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatYearRangeString(t *testing.T) {
|
||||
stringPtr := func(v string) *string { return &v }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start *string
|
||||
end *string
|
||||
want string
|
||||
}{
|
||||
{"both nil", nil, nil, ""},
|
||||
{"only start", stringPtr("2005"), nil, "2005 -"},
|
||||
{"only end", nil, stringPtr("2010"), "- 2010"},
|
||||
{"start and end", stringPtr("2005"), stringPtr("2010"), "2005 - 2010"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatYearRangeString(tt.start, tt.end)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYearRangeString(t *testing.T) {
|
||||
intPtr := func(v int) *int { return &v }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantStart *int
|
||||
wantEnd *int
|
||||
wantErr bool
|
||||
}{
|
||||
{"single year", "2005", intPtr(2005), nil, false},
|
||||
{"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false},
|
||||
{"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false},
|
||||
{"year dash open", "2005 -", intPtr(2005), nil, false},
|
||||
{"year dash open no space", "2005-", intPtr(2005), nil, false},
|
||||
{"dash year", "- 2010", nil, intPtr(2010), false},
|
||||
{"year present", "2005-present", intPtr(2005), nil, false},
|
||||
{"year Present caps", "2005 - Present", intPtr(2005), nil, false},
|
||||
{"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false},
|
||||
{"empty string", "", nil, nil, true},
|
||||
{"garbage", "not a year", nil, nil, true},
|
||||
{"partial garbage start", "abc - 2010", nil, nil, true},
|
||||
{"partial garbage end", "2005 - abc", nil, nil, true},
|
||||
{"year out of range", "1800", nil, nil, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, end, err := ParseYearRangeString(tt.input)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
if tt.wantStart != nil {
|
||||
assert.NotNil(t, start)
|
||||
assert.Equal(t, *tt.wantStart, start.Time.Year())
|
||||
} else {
|
||||
assert.Nil(t, start)
|
||||
}
|
||||
if tt.wantEnd != nil {
|
||||
assert.NotNil(t, end)
|
||||
assert.Equal(t, *tt.wantEnd, end.Time.Year())
|
||||
} else {
|
||||
assert.Nil(t, end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ type Performer struct {
|
|||
PenisLength float64 `json:"penis_length,omitempty"`
|
||||
Circumcised string `json:"circumcised,omitempty"`
|
||||
CareerLength string `json:"career_length,omitempty"` // deprecated - for import only
|
||||
CareerStart *int `json:"career_start,omitempty"`
|
||||
CareerEnd *int `json:"career_end,omitempty"`
|
||||
CareerStart string `json:"career_start,omitempty"`
|
||||
CareerEnd string `json:"career_end,omitempty"`
|
||||
Tattoos string `json:"tattoos,omitempty"`
|
||||
Piercings string `json:"piercings,omitempty"`
|
||||
Aliases StringOrStringList `json:"aliases,omitempty"`
|
||||
|
|
|
|||
|
|
@ -153,13 +153,13 @@ func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string, case
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FindAllInPaths provides a mock function with given fields: ctx, p, limit, offset
|
||||
func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, limit int, offset int) ([]models.File, error) {
|
||||
ret := _m.Called(ctx, p, limit, offset)
|
||||
// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset
|
||||
func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]models.File, error) {
|
||||
ret := _m.Called(ctx, p, includeZipContents, limit, offset)
|
||||
|
||||
var r0 []models.File
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []string, int, int) []models.File); ok {
|
||||
r0 = rf(ctx, p, limit, offset)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []models.File); ok {
|
||||
r0 = rf(ctx, p, includeZipContents, limit, offset)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]models.File)
|
||||
|
|
@ -167,8 +167,8 @@ func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, limi
|
|||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []string, int, int) error); ok {
|
||||
r1 = rf(ctx, p, limit, offset)
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok {
|
||||
r1 = rf(ctx, p, includeZipContents, limit, offset)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,13 +86,13 @@ func (_m *FolderReaderWriter) Find(ctx context.Context, id models.FolderID) (*mo
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FindAllInPaths provides a mock function with given fields: ctx, p, limit, offset
|
||||
func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, limit int, offset int) ([]*models.Folder, error) {
|
||||
ret := _m.Called(ctx, p, limit, offset)
|
||||
// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset
|
||||
func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]*models.Folder, error) {
|
||||
ret := _m.Called(ctx, p, includeZipContents, limit, offset)
|
||||
|
||||
var r0 []*models.Folder
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []string, int, int) []*models.Folder); ok {
|
||||
r0 = rf(ctx, p, limit, offset)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []*models.Folder); ok {
|
||||
r0 = rf(ctx, p, includeZipContents, limit, offset)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Folder)
|
||||
|
|
@ -100,8 +100,8 @@ func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, li
|
|||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []string, int, int) error); ok {
|
||||
r1 = rf(ctx, p, limit, offset)
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok {
|
||||
r1 = rf(ctx, p, includeZipContents, limit, offset)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,26 +6,26 @@ import (
|
|||
)
|
||||
|
||||
type Performer struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *Date `json:"birthdate"`
|
||||
Ethnicity string `json:"ethnicity"`
|
||||
Country string `json:"country"`
|
||||
EyeColor string `json:"eye_color"`
|
||||
Height *int `json:"height"`
|
||||
Measurements string `json:"measurements"`
|
||||
FakeTits string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumisedEnum `json:"circumcised"`
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
Tattoos string `json:"tattoos"`
|
||||
Piercings string `json:"piercings"`
|
||||
Favorite bool `json:"favorite"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *Date `json:"birthdate"`
|
||||
Ethnicity string `json:"ethnicity"`
|
||||
Country string `json:"country"`
|
||||
EyeColor string `json:"eye_color"`
|
||||
Height *int `json:"height"`
|
||||
Measurements string `json:"measurements"`
|
||||
FakeTits string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumcisedEnum `json:"circumcised"`
|
||||
CareerStart *Date `json:"career_start"`
|
||||
CareerEnd *Date `json:"career_end"`
|
||||
Tattoos string `json:"tattoos"`
|
||||
Piercings string `json:"piercings"`
|
||||
Favorite bool `json:"favorite"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Rating expressed in 1-100 scale
|
||||
Rating *int `json:"rating"`
|
||||
Details string `json:"details"`
|
||||
|
|
@ -76,8 +76,8 @@ type PerformerPartial struct {
|
|||
FakeTits OptionalString
|
||||
PenisLength OptionalFloat64
|
||||
Circumcised OptionalString
|
||||
CareerStart OptionalInt
|
||||
CareerEnd OptionalInt
|
||||
CareerStart OptionalDate
|
||||
CareerEnd OptionalDate
|
||||
Tattoos OptionalString
|
||||
Piercings OptionalString
|
||||
Favorite OptionalBool
|
||||
|
|
|
|||
|
|
@ -177,8 +177,8 @@ type ScrapedPerformer struct {
|
|||
PenisLength *string `json:"penis_length"`
|
||||
Circumcised *string `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"` // deprecated: use CareerStart/CareerEnd
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
CareerStart *string `json:"career_start"`
|
||||
CareerEnd *string `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
|
|
@ -225,12 +225,16 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
|
|||
// assume that career length is _not_ populated in favour of start/end
|
||||
|
||||
if p.CareerStart != nil && !excluded["career_start"] {
|
||||
cs := *p.CareerStart
|
||||
ret.CareerStart = &cs
|
||||
date, err := ParseDate(*p.CareerStart)
|
||||
if err == nil {
|
||||
ret.CareerStart = &date
|
||||
}
|
||||
}
|
||||
if p.CareerEnd != nil && !excluded["career_end"] {
|
||||
ce := *p.CareerEnd
|
||||
ret.CareerEnd = &ce
|
||||
date, err := ParseDate(*p.CareerEnd)
|
||||
if err == nil {
|
||||
ret.CareerEnd = &date
|
||||
}
|
||||
}
|
||||
if p.Country != nil && !excluded["country"] {
|
||||
ret.Country = *p.Country
|
||||
|
|
@ -288,7 +292,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
|
|||
}
|
||||
}
|
||||
if p.Circumcised != nil && !excluded["circumcised"] {
|
||||
v := CircumisedEnum(*p.Circumcised)
|
||||
v := CircumcisedEnum(*p.Circumcised)
|
||||
if v.IsValid() {
|
||||
ret.Circumcised = &v
|
||||
}
|
||||
|
|
@ -367,13 +371,13 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
|
|||
}
|
||||
if p.CareerLength != nil && !excluded["career_length"] {
|
||||
// parse career_length into career_start/career_end
|
||||
start, end, err := utils.ParseYearRangeString(*p.CareerLength)
|
||||
start, end, err := ParseYearRangeString(*p.CareerLength)
|
||||
if err == nil {
|
||||
if start != nil {
|
||||
ret.CareerStart = NewOptionalInt(*start)
|
||||
ret.CareerStart = NewOptionalDate(*start)
|
||||
}
|
||||
if end != nil {
|
||||
ret.CareerEnd = NewOptionalInt(*end)
|
||||
ret.CareerEnd = NewOptionalDate(*end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -471,11 +475,12 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
|
|||
|
||||
type ScrapedTag struct {
|
||||
// Set if tag matched
|
||||
StoredID *string `json:"stored_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
RemoteSiteID *string `json:"remote_site_id"`
|
||||
StoredID *string `json:"stored_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
RemoteSiteID *string `json:"remote_site_id"`
|
||||
Parent *ScrapedTag `json:"parent"`
|
||||
}
|
||||
|
||||
func (ScrapedTag) IsScrapedContent() {}
|
||||
|
|
@ -496,6 +501,13 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
|
|||
ret.Aliases = NewRelatedStrings(t.AliasList)
|
||||
}
|
||||
|
||||
if t.Parent != nil && t.Parent.StoredID != nil {
|
||||
parentID, err := strconv.Atoi(*t.Parent.StoredID)
|
||||
if err == nil && parentID > 0 {
|
||||
ret.ParentIDs = NewRelatedIDs([]int{parentID})
|
||||
}
|
||||
}
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
|
||||
ret.StashIDs = NewRelatedStashIDs([]StashID{
|
||||
{
|
||||
|
|
@ -527,6 +539,16 @@ func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[st
|
|||
}
|
||||
}
|
||||
|
||||
if t.Parent != nil && t.Parent.StoredID != nil {
|
||||
parentID, err := strconv.Atoi(*t.Parent.StoredID)
|
||||
if err == nil && parentID > 0 {
|
||||
ret.ParentIDs = &UpdateIDs{
|
||||
IDs: []int{parentID},
|
||||
Mode: RelationshipUpdateModeAdd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
|
||||
ret.StashIDs = &UpdateStashIDs{
|
||||
StashIDs: existingStashIDs,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func Test_scrapedToStudioInput(t *testing.T) {
|
||||
const name = "name"
|
||||
url := "url"
|
||||
|
|
@ -186,8 +184,8 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
|||
Weight: nextVal(),
|
||||
Measurements: nextVal(),
|
||||
FakeTits: nextVal(),
|
||||
CareerStart: intPtr(2005),
|
||||
CareerEnd: intPtr(2015),
|
||||
CareerStart: dateStrFromInt(2005),
|
||||
CareerEnd: dateStrFromInt(2015),
|
||||
Tattoos: nextVal(),
|
||||
Piercings: nextVal(),
|
||||
Aliases: nextVal(),
|
||||
|
|
@ -212,8 +210,8 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
|||
Weight: nextIntVal(),
|
||||
Measurements: *nextVal(),
|
||||
FakeTits: *nextVal(),
|
||||
CareerStart: intPtr(2005),
|
||||
CareerEnd: intPtr(2015),
|
||||
CareerStart: dateFromInt(2005),
|
||||
CareerEnd: dateFromInt(2015),
|
||||
Tattoos: *nextVal(), // skip CareerLength counter slot
|
||||
Piercings: *nextVal(),
|
||||
Aliases: NewRelatedStrings([]string{*nextVal()}),
|
||||
|
|
|
|||
|
|
@ -61,49 +61,49 @@ type GenderCriterionInput struct {
|
|||
Modifier CriterionModifier `json:"modifier"`
|
||||
}
|
||||
|
||||
type CircumisedEnum string
|
||||
type CircumcisedEnum string
|
||||
|
||||
const (
|
||||
CircumisedEnumCut CircumisedEnum = "CUT"
|
||||
CircumisedEnumUncut CircumisedEnum = "UNCUT"
|
||||
CircumcisedEnumCut CircumcisedEnum = "CUT"
|
||||
CircumcisedEnumUncut CircumcisedEnum = "UNCUT"
|
||||
)
|
||||
|
||||
var AllCircumcisionEnum = []CircumisedEnum{
|
||||
CircumisedEnumCut,
|
||||
CircumisedEnumUncut,
|
||||
var AllCircumcisionEnum = []CircumcisedEnum{
|
||||
CircumcisedEnumCut,
|
||||
CircumcisedEnumUncut,
|
||||
}
|
||||
|
||||
func (e CircumisedEnum) IsValid() bool {
|
||||
func (e CircumcisedEnum) IsValid() bool {
|
||||
switch e {
|
||||
case CircumisedEnumCut, CircumisedEnumUncut:
|
||||
case CircumcisedEnumCut, CircumcisedEnumUncut:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e CircumisedEnum) String() string {
|
||||
func (e CircumcisedEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *CircumisedEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *CircumcisedEnum) UnmarshalGQL(v interface{}) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = CircumisedEnum(str)
|
||||
*e = CircumcisedEnum(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid CircumisedEnum", str)
|
||||
return fmt.Errorf("%s is not a valid CircumcisedEnum", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e CircumisedEnum) MarshalGQL(w io.Writer) {
|
||||
func (e CircumcisedEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
type CircumcisionCriterionInput struct {
|
||||
Value []CircumisedEnum `json:"value"`
|
||||
Value []CircumcisedEnum `json:"value"`
|
||||
Modifier CriterionModifier `json:"modifier"`
|
||||
}
|
||||
|
||||
|
|
@ -139,9 +139,9 @@ type PerformerFilterType struct {
|
|||
// Filter by career length
|
||||
CareerLength *StringCriterionInput `json:"career_length"` // deprecated
|
||||
// Filter by career start year
|
||||
CareerStart *IntCriterionInput `json:"career_start"`
|
||||
CareerStart *DateCriterionInput `json:"career_start"`
|
||||
// Filter by career end year
|
||||
CareerEnd *IntCriterionInput `json:"career_end"`
|
||||
CareerEnd *DateCriterionInput `json:"career_end"`
|
||||
// Filter by tattoos
|
||||
Tattoos *StringCriterionInput `json:"tattoos"`
|
||||
// Filter by piercings
|
||||
|
|
@ -216,32 +216,32 @@ type PerformerFilterType struct {
|
|||
}
|
||||
|
||||
type PerformerCreateInput struct {
|
||||
Name string `json:"name"`
|
||||
Disambiguation *string `json:"disambiguation"`
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *string `json:"birthdate"`
|
||||
Ethnicity *string `json:"ethnicity"`
|
||||
Country *string `json:"country"`
|
||||
EyeColor *string `json:"eye_color"`
|
||||
Height *string `json:"height"`
|
||||
HeightCm *int `json:"height_cm"`
|
||||
Measurements *string `json:"measurements"`
|
||||
FakeTits *string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumisedEnum `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
Twitter *string `json:"twitter"` // deprecated
|
||||
Instagram *string `json:"instagram"` // deprecated
|
||||
Favorite *bool `json:"favorite"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
Name string `json:"name"`
|
||||
Disambiguation *string `json:"disambiguation"`
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *string `json:"birthdate"`
|
||||
Ethnicity *string `json:"ethnicity"`
|
||||
Country *string `json:"country"`
|
||||
EyeColor *string `json:"eye_color"`
|
||||
Height *string `json:"height"`
|
||||
HeightCm *int `json:"height_cm"`
|
||||
Measurements *string `json:"measurements"`
|
||||
FakeTits *string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumcisedEnum `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *string `json:"career_start"`
|
||||
CareerEnd *string `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
Twitter *string `json:"twitter"` // deprecated
|
||||
Instagram *string `json:"instagram"` // deprecated
|
||||
Favorite *bool `json:"favorite"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
// This should be a URL or a base64 encoded data URL
|
||||
Image *string `json:"image"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
|
|
@ -256,33 +256,33 @@ type PerformerCreateInput struct {
|
|||
}
|
||||
|
||||
type PerformerUpdateInput struct {
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Disambiguation *string `json:"disambiguation"`
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *string `json:"birthdate"`
|
||||
Ethnicity *string `json:"ethnicity"`
|
||||
Country *string `json:"country"`
|
||||
EyeColor *string `json:"eye_color"`
|
||||
Height *string `json:"height"`
|
||||
HeightCm *int `json:"height_cm"`
|
||||
Measurements *string `json:"measurements"`
|
||||
FakeTits *string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumisedEnum `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
Twitter *string `json:"twitter"` // deprecated
|
||||
Instagram *string `json:"instagram"` // deprecated
|
||||
Favorite *bool `json:"favorite"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Disambiguation *string `json:"disambiguation"`
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *string `json:"birthdate"`
|
||||
Ethnicity *string `json:"ethnicity"`
|
||||
Country *string `json:"country"`
|
||||
EyeColor *string `json:"eye_color"`
|
||||
Height *string `json:"height"`
|
||||
HeightCm *int `json:"height_cm"`
|
||||
Measurements *string `json:"measurements"`
|
||||
FakeTits *string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumcisedEnum `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *string `json:"career_start"`
|
||||
CareerEnd *string `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
Twitter *string `json:"twitter"` // deprecated
|
||||
Instagram *string `json:"instagram"` // deprecated
|
||||
Favorite *bool `json:"favorite"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
// This should be a URL or a base64 encoded data URL
|
||||
Image *string `json:"image"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ type FileGetter interface {
|
|||
type FileFinder interface {
|
||||
FileGetter
|
||||
FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]File, error)
|
||||
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]File, error)
|
||||
FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]File, error)
|
||||
FindByPath(ctx context.Context, path string, caseSensitive bool) (File, error)
|
||||
FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error)
|
||||
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]File, error)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ type FolderGetter interface {
|
|||
// FolderFinder provides methods to find folders.
|
||||
type FolderFinder interface {
|
||||
FolderGetter
|
||||
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*Folder, error)
|
||||
FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*Folder, error)
|
||||
FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error)
|
||||
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error)
|
||||
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
|
||||
|
|
|
|||
|
|
@ -71,10 +71,10 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode
|
|||
}
|
||||
|
||||
if performer.CareerStart != nil {
|
||||
newPerformerJSON.CareerStart = performer.CareerStart
|
||||
newPerformerJSON.CareerStart = performer.CareerStart.String()
|
||||
}
|
||||
if performer.CareerEnd != nil {
|
||||
newPerformerJSON.CareerEnd = performer.CareerEnd
|
||||
newPerformerJSON.CareerEnd = performer.CareerEnd.String()
|
||||
}
|
||||
|
||||
if err := performer.LoadAliases(ctx, reader); err != nil {
|
||||
|
|
|
|||
|
|
@ -48,10 +48,10 @@ var (
|
|||
rating = 5
|
||||
height = 123
|
||||
weight = 60
|
||||
careerStart = 2005
|
||||
careerEnd = 2015
|
||||
careerStart, _ = models.ParseDate("2005")
|
||||
careerEnd, _ = models.ParseDate("2015")
|
||||
penisLength = 1.23
|
||||
circumcisedEnum = models.CircumisedEnumCut
|
||||
circumcisedEnum = models.CircumcisedEnumCut
|
||||
circumcised = circumcisedEnum.String()
|
||||
|
||||
emptyCustomFields = make(map[string]interface{})
|
||||
|
|
@ -134,8 +134,8 @@ func createFullJSONPerformer(name string, image string, withCustomFields bool) *
|
|||
URLs: []string{url, twitter, instagram},
|
||||
Aliases: aliases,
|
||||
Birthdate: birthDate.String(),
|
||||
CareerStart: &careerStart,
|
||||
CareerEnd: &careerEnd,
|
||||
CareerStart: careerStart.String(),
|
||||
CareerEnd: careerEnd.String(),
|
||||
Country: country,
|
||||
Ethnicity: ethnicity,
|
||||
EyeColor: eyeColor,
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Perfor
|
|||
}
|
||||
|
||||
if performerJSON.Circumcised != "" {
|
||||
v := models.CircumisedEnum(performerJSON.Circumcised)
|
||||
v := models.CircumcisedEnum(performerJSON.Circumcised)
|
||||
newPerformer.Circumcised = &v
|
||||
}
|
||||
|
||||
|
|
@ -285,11 +285,17 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Perfor
|
|||
}
|
||||
|
||||
// prefer explicit career_start/career_end, fall back to parsing legacy career_length
|
||||
if performerJSON.CareerStart != nil || performerJSON.CareerEnd != nil {
|
||||
newPerformer.CareerStart = performerJSON.CareerStart
|
||||
newPerformer.CareerEnd = performerJSON.CareerEnd
|
||||
if performerJSON.CareerStart != "" || performerJSON.CareerEnd != "" {
|
||||
careerStart, err := models.ParseDate(performerJSON.CareerStart)
|
||||
if err == nil {
|
||||
newPerformer.CareerStart = &careerStart
|
||||
}
|
||||
careerEnd, err := models.ParseDate(performerJSON.CareerEnd)
|
||||
if err == nil {
|
||||
newPerformer.CareerEnd = &careerEnd
|
||||
}
|
||||
} else if performerJSON.CareerLength != "" {
|
||||
start, end, err := utils.ParseYearRangeString(performerJSON.CareerLength)
|
||||
start, end, err := models.ParseYearRangeString(performerJSON.CareerLength)
|
||||
if err != nil {
|
||||
return models.Performer{}, fmt.Errorf("invalid career_length %q: %w", performerJSON.CareerLength, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,15 +317,15 @@ func TestUpdate(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestImportCareerFields(t *testing.T) {
|
||||
startYear := 2005
|
||||
endYear := 2015
|
||||
startYear, _ := models.ParseDate("2005")
|
||||
endYear, _ := models.ParseDate("2015")
|
||||
|
||||
// explicit career_start/career_end should be used directly
|
||||
t.Run("explicit fields", func(t *testing.T) {
|
||||
input := jsonschema.Performer{
|
||||
Name: "test",
|
||||
CareerStart: &startYear,
|
||||
CareerEnd: &endYear,
|
||||
CareerStart: startYear.String(),
|
||||
CareerEnd: endYear.String(),
|
||||
}
|
||||
|
||||
p, err := performerJSONToPerformer(input)
|
||||
|
|
@ -338,8 +338,8 @@ func TestImportCareerFields(t *testing.T) {
|
|||
t.Run("explicit fields override legacy", func(t *testing.T) {
|
||||
input := jsonschema.Performer{
|
||||
Name: "test",
|
||||
CareerStart: &startYear,
|
||||
CareerEnd: &endYear,
|
||||
CareerStart: startYear.String(),
|
||||
CareerEnd: endYear.String(),
|
||||
CareerLength: "1990 - 1995",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,8 +140,8 @@ func (r mappedResult) scrapedPerformer() *models.ScrapedPerformer {
|
|||
PenisLength: r.stringPtr("PenisLength"),
|
||||
Circumcised: r.stringPtr("Circumcised"),
|
||||
CareerLength: r.stringPtr("CareerLength"),
|
||||
CareerStart: r.IntPtr("CareerStart"),
|
||||
CareerEnd: r.IntPtr("CareerEnd"),
|
||||
CareerStart: r.stringPtr("CareerStart"),
|
||||
CareerEnd: r.stringPtr("CareerEnd"),
|
||||
Tattoos: r.stringPtr("Tattoos"),
|
||||
Piercings: r.stringPtr("Piercings"),
|
||||
Aliases: r.stringPtr("Aliases"),
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ type ScrapedPerformerInput struct {
|
|||
PenisLength *string `json:"penis_length"`
|
||||
Circumcised *string `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
CareerStart *string `json:"career_start"`
|
||||
CareerEnd *string `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
|
|
|
|||
144
pkg/scraper/post_processing_test.go
Normal file
144
pkg/scraper/post_processing_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package scraper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func TestPostScrapePerformerCareerLength(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const related = false
|
||||
|
||||
strPtr := func(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input models.ScrapedPerformer
|
||||
want models.ScrapedPerformer
|
||||
}{
|
||||
{
|
||||
"start = 2000",
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerLength: strPtr("2000 -"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"end = 2000",
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("2000"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("2000"),
|
||||
CareerLength: strPtr("- 2000"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"start = 2000, end = 2020",
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerEnd: strPtr("2020"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerEnd: strPtr("2020"),
|
||||
CareerLength: strPtr("2000 - 2020"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"length = 2000 -",
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("2000 -"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerLength: strPtr("2000 -"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"length = - 2010",
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("- 2010"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("2010"),
|
||||
CareerLength: strPtr("- 2010"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"length = 2000 - 2010",
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("2000 - 2010"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerEnd: strPtr("2010"),
|
||||
CareerLength: strPtr("2000 - 2010"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid start",
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("two thousand"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("two thousand"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid end",
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("two thousand"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("two thousand"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid career length",
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("1234 - 4567 - 9224"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("1234 - 4567 - 9224"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
compareStrPtr := func(a, b *string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &postScraper{}
|
||||
got, err := c.postScrapePerformer(ctx, tt.input, related)
|
||||
if err != nil {
|
||||
t.Fatalf("postScrapePerformer returned error: %v", err)
|
||||
}
|
||||
postScraped := got.(models.ScrapedPerformer)
|
||||
if !compareStrPtr(postScraped.CareerStart, tt.want.CareerStart) {
|
||||
t.Errorf("CareerStart = %v, want %v", postScraped.CareerStart, tt.want.CareerStart)
|
||||
}
|
||||
if !compareStrPtr(postScraped.CareerEnd, tt.want.CareerEnd) {
|
||||
t.Errorf("CareerEnd = %v, want %v", postScraped.CareerEnd, tt.want.CareerEnd)
|
||||
}
|
||||
if !compareStrPtr(postScraped.CareerLength, tt.want.CareerLength) {
|
||||
t.Errorf("CareerLength = %v, want %v", postScraped.CareerLength, tt.want.CareerLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -125,23 +125,64 @@ func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedP
|
|||
}
|
||||
}
|
||||
|
||||
isEmptyStr := func(s *string) bool { return s == nil || *s == "" }
|
||||
isEmptyInt := func(s *int) bool { return s == nil || *s == 0 }
|
||||
|
||||
// populate career start/end from career length and vice versa
|
||||
if !isEmptyStr(p.CareerLength) && isEmptyInt(p.CareerStart) && isEmptyInt(p.CareerEnd) {
|
||||
p.CareerStart, p.CareerEnd, err = utils.ParseYearRangeString(*p.CareerLength)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err)
|
||||
}
|
||||
} else if isEmptyStr(p.CareerLength) && (!isEmptyInt(p.CareerStart) || !isEmptyInt(p.CareerEnd)) {
|
||||
v := utils.FormatYearRange(p.CareerStart, p.CareerEnd)
|
||||
p.CareerLength = &v
|
||||
}
|
||||
c.postProcessCareerLength(&p)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c *postScraper) postProcessCareerLength(p *models.ScrapedPerformer) {
|
||||
isEmptyStr := func(s *string) bool { return s == nil || *s == "" }
|
||||
|
||||
// populate career start/end from career length and vice versa
|
||||
if !isEmptyStr(p.CareerLength) && isEmptyStr(p.CareerStart) && isEmptyStr(p.CareerEnd) {
|
||||
start, end, err := models.ParseYearRangeString(*p.CareerLength)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err)
|
||||
return
|
||||
}
|
||||
|
||||
if start != nil {
|
||||
startStr := start.String()
|
||||
p.CareerStart = &startStr
|
||||
}
|
||||
if end != nil {
|
||||
endStr := end.String()
|
||||
p.CareerEnd = &endStr
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// populate career length from career start/end if career length is missing
|
||||
if isEmptyStr(p.CareerLength) {
|
||||
var (
|
||||
start *models.Date
|
||||
end *models.Date
|
||||
)
|
||||
|
||||
if !isEmptyStr(p.CareerStart) {
|
||||
date, err := models.ParseDate(*p.CareerStart)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not parse career start %s: %v", *p.CareerStart, err)
|
||||
return
|
||||
}
|
||||
start = &date
|
||||
}
|
||||
|
||||
if !isEmptyStr(p.CareerEnd) {
|
||||
date, err := models.ParseDate(*p.CareerEnd)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not parse career end %s: %v", *p.CareerEnd, err)
|
||||
return
|
||||
}
|
||||
end = &date
|
||||
}
|
||||
|
||||
v := models.FormatYearRange(start, end)
|
||||
p.CareerLength = &v
|
||||
}
|
||||
}
|
||||
|
||||
func (c *postScraper) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, related bool) (_ ScrapedContent, err error) {
|
||||
r := c.repository
|
||||
tqb := r.TagFinder
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ func queryURLParametersFromScene(scene *models.Scene) queryURLParameters {
|
|||
ret["oshash"] = scene.OSHash
|
||||
ret["filename"] = filepath.Base(scene.Path)
|
||||
|
||||
// pull phash from primary file
|
||||
phashFingerprints := scene.Files.Primary().Base().Fingerprints.Filter(models.FingerprintTypePhash)
|
||||
if len(phashFingerprints) > 0 {
|
||||
ret["phash"] = phashFingerprints[0].Value()
|
||||
}
|
||||
|
||||
if scene.Title != "" {
|
||||
ret["title"] = scene.Title
|
||||
}
|
||||
|
|
|
|||
|
|
@ -700,7 +700,7 @@ func (qb *FileStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectD
|
|||
// FindAllByPaths returns the all files that are within any of the given paths.
|
||||
// Returns all if limit is < 0.
|
||||
// Returns all files if p is empty.
|
||||
func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]models.File, error) {
|
||||
func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]models.File, error) {
|
||||
table := qb.table()
|
||||
folderTable := folderTableMgr.table
|
||||
|
||||
|
|
@ -711,6 +711,10 @@ func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, limit, offs
|
|||
|
||||
q = qb.allInPaths(q, p)
|
||||
|
||||
if !includeZipContents {
|
||||
q = q.Where(table.Col("zip_file_id").IsNull())
|
||||
}
|
||||
|
||||
if limit > -1 {
|
||||
q = q.Limit(uint(limit))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,22 +238,32 @@ func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.Fingerprint
|
|||
t := fmt.Sprintf("file_fingerprints_%d", i)
|
||||
f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type)
|
||||
|
||||
value, _ := utils.StringToPhash(hash.Value)
|
||||
distance := 0
|
||||
if hash.Distance != nil {
|
||||
distance = *hash.Distance
|
||||
}
|
||||
|
||||
if distance > 0 {
|
||||
// needed to avoid a type mismatch
|
||||
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
|
||||
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
|
||||
// Only phash supports distance matching and is stored as integer
|
||||
if hash.Type == models.FingerprintTypePhash {
|
||||
value, err := utils.StringToPhash(hash.Value)
|
||||
if err != nil {
|
||||
f.setError(fmt.Errorf("invalid phash value: %w", err))
|
||||
return
|
||||
}
|
||||
if distance > 0 {
|
||||
// needed to avoid a type mismatch
|
||||
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
|
||||
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
|
||||
} else {
|
||||
intCriterionHandler(&models.IntCriterionInput{
|
||||
Value: int(value),
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}, t+".fingerprint", nil)(ctx, f)
|
||||
}
|
||||
} else {
|
||||
// use the default handler
|
||||
intCriterionHandler(&models.IntCriterionInput{
|
||||
Value: int(value),
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}, t+".fingerprint", nil)(ctx, f)
|
||||
// All other fingerprint types (md5, oshash, sha1, etc.) are stored as strings
|
||||
// Use exact match for string-based fingerprints
|
||||
f.addWhere(fmt.Sprintf("%s.fingerprint = ?", t), hash.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -81,7 +82,45 @@ func TestFileQuery(t *testing.T) {
|
|||
includeIDs: []models.FileID{fileIDs[fileIdxInZip]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
// TODO - add more tests for other file filters
|
||||
{
|
||||
name: "hashes md5",
|
||||
filter: &models.FileFilterType{
|
||||
Hashes: []*models.FingerprintFilterInput{
|
||||
{
|
||||
Type: models.FingerprintTypeMD5,
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "md5"),
|
||||
},
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "hashes oshash",
|
||||
filter: &models.FileFilterType{
|
||||
Hashes: []*models.FingerprintFilterInput{
|
||||
{
|
||||
Type: models.FingerprintTypeOshash,
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "oshash"),
|
||||
},
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "hashes phash",
|
||||
filter: &models.FileFilterType{
|
||||
Hashes: []*models.FingerprintFilterInput{
|
||||
{
|
||||
Type: models.FingerprintTypePhash,
|
||||
Value: utils.PhashToString(getFilePhash(fileIdxStartImageFiles)),
|
||||
},
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartImageFiles},
|
||||
excludeIdxs: []int{fileIdxStartVideoFiles},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -576,7 +576,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
|
|||
{
|
||||
"by MD5",
|
||||
models.Fingerprint{
|
||||
Type: "MD5",
|
||||
Type: models.FingerprintTypeMD5,
|
||||
Fingerprint: getPrefixedStringValue("file", fileIdxZip, "md5"),
|
||||
},
|
||||
[]models.File{makeFileWithID(fileIdxZip)},
|
||||
|
|
@ -585,7 +585,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
|
|||
{
|
||||
"by OSHASH",
|
||||
models.Fingerprint{
|
||||
Type: "OSHASH",
|
||||
Type: models.FingerprintTypeOshash,
|
||||
Fingerprint: getPrefixedStringValue("file", fileIdxZip, "oshash"),
|
||||
},
|
||||
[]models.File{makeFileWithID(fileIdxZip)},
|
||||
|
|
@ -594,7 +594,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
|
|||
{
|
||||
"non-existing",
|
||||
models.Fingerprint{
|
||||
Type: "OSHASH",
|
||||
Type: models.FingerprintTypeOshash,
|
||||
Fingerprint: "foo",
|
||||
},
|
||||
nil,
|
||||
|
|
|
|||
|
|
@ -427,10 +427,14 @@ func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.Selec
|
|||
// FindAllInPaths returns the all folders that are or are within any of the given paths.
|
||||
// Returns all if limit is < 0.
|
||||
// Returns all folders if p is empty.
|
||||
func (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*models.Folder, error) {
|
||||
func (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*models.Folder, error) {
|
||||
q := qb.selectDataset().Prepared(true)
|
||||
q = qb.allInPaths(q, p)
|
||||
|
||||
if !includeZipContents {
|
||||
q = q.Where(qb.table().Col("zip_file_id").IsNull())
|
||||
}
|
||||
|
||||
if limit > -1 {
|
||||
q = q.Limit(uint(limit))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -837,7 +837,7 @@ func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFi
|
|||
)
|
||||
|
||||
filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename"
|
||||
searchColumns := []string{"images.title", filepathColumn, "files_fingerprints.fingerprint"}
|
||||
searchColumns := []string{"images.title", "images.details", filepathColumn, "files_fingerprints.fingerprint"}
|
||||
query.parseQueryString(searchColumns, *q)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1596,6 +1596,20 @@ func TestImageQueryQ(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestImageQueryQ_Details(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
const imageIdx = 3
|
||||
|
||||
q := getImageStringValue(imageIdx, detailsField)
|
||||
|
||||
sqb := db.Image
|
||||
|
||||
imageQueryQ(ctx, t, sqb, q, imageIdx)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func queryImagesWithCount(ctx context.Context, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) {
|
||||
result, err := sqb.Query(ctx, models.ImageQueryOptions{
|
||||
QueryOptions: models.QueryOptions{
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import (
|
|||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sqlite"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type schema78Migrator struct {
|
||||
|
|
@ -76,7 +76,7 @@ func (m *schema78Migrator) migrateCareerLength(ctx context.Context) error {
|
|||
lastID = id
|
||||
gotSome = true
|
||||
|
||||
start, end, err := utils.ParseYearRangeString(careerLength)
|
||||
start, end, err := models.ParseYearRangeString(careerLength)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not parse career_length %q for performer %d: %v — preserving as custom field", careerLength, id, err)
|
||||
|
||||
|
|
@ -107,10 +107,23 @@ func (m *schema78Migrator) migrateCareerLength(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *schema78Migrator) updateCareerFields(tx *sqlx.Tx, id int, start *int, end *int) error {
|
||||
func (m *schema78Migrator) updateCareerFields(tx *sqlx.Tx, id int, start *models.Date, end *models.Date) error {
|
||||
var (
|
||||
startYear, endYear *int
|
||||
)
|
||||
|
||||
if start != nil {
|
||||
year := start.Year()
|
||||
startYear = &year
|
||||
}
|
||||
if end != nil {
|
||||
year := end.Year()
|
||||
endYear = &year
|
||||
}
|
||||
|
||||
_, err := tx.Exec(
|
||||
"UPDATE performers SET career_start = ?, career_end = ? WHERE id = ?",
|
||||
start, end, id,
|
||||
startYear, endYear, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
112
pkg/sqlite/migrations/85_performer_career_dates.up.sql
Normal file
112
pkg/sqlite/migrations/85_performer_career_dates.up.sql
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
-- have to change the type of the career start/end columns so need to recreate the table
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "performers_new" (
|
||||
`id` integer not null primary key autoincrement,
|
||||
`name` varchar(255) not null,
|
||||
`disambiguation` varchar(255),
|
||||
`gender` varchar(20),
|
||||
`birthdate` date,
|
||||
`birthdate_precision` TINYINT,
|
||||
`ethnicity` varchar(255),
|
||||
`country` varchar(255),
|
||||
`eye_color` varchar(255),
|
||||
`height` int,
|
||||
`measurements` varchar(255),
|
||||
`fake_tits` varchar(255),
|
||||
`tattoos` varchar(255),
|
||||
`piercings` varchar(255),
|
||||
`favorite` boolean not null default '0',
|
||||
`created_at` datetime not null,
|
||||
`updated_at` datetime not null,
|
||||
`details` text,
|
||||
`death_date` date,
|
||||
`death_date_precision` TINYINT,
|
||||
`hair_color` varchar(255),
|
||||
`weight` integer,
|
||||
`rating` tinyint,
|
||||
`ignore_auto_tag` boolean not null default '0',
|
||||
`penis_length` float,
|
||||
`circumcised` varchar[10],
|
||||
`career_start` date,
|
||||
`career_start_precision` TINYINT,
|
||||
`career_end` date,
|
||||
`career_end_precision` TINYINT,
|
||||
`image_blob` varchar(255) REFERENCES `blobs`(`checksum`)
|
||||
);
|
||||
|
||||
INSERT INTO `performers_new` (
|
||||
`id`,
|
||||
`name`,
|
||||
`disambiguation`,
|
||||
`gender`,
|
||||
`birthdate`,
|
||||
`ethnicity`,
|
||||
`country`,
|
||||
`eye_color`,
|
||||
`height`,
|
||||
`measurements`,
|
||||
`fake_tits`,
|
||||
`tattoos`,
|
||||
`piercings`,
|
||||
`favorite`,
|
||||
`created_at`,
|
||||
`updated_at`,
|
||||
`details`,
|
||||
`death_date`,
|
||||
`hair_color`,
|
||||
`weight`,
|
||||
`rating`,
|
||||
`ignore_auto_tag`,
|
||||
`image_blob`,
|
||||
`penis_length`,
|
||||
`circumcised`,
|
||||
`birthdate_precision`,
|
||||
`death_date_precision`,
|
||||
`career_start`,
|
||||
`career_end`
|
||||
) SELECT
|
||||
`id`,
|
||||
`name`,
|
||||
`disambiguation`,
|
||||
`gender`,
|
||||
`birthdate`,
|
||||
`ethnicity`,
|
||||
`country`,
|
||||
`eye_color`,
|
||||
`height`,
|
||||
`measurements`,
|
||||
`fake_tits`,
|
||||
`tattoos`,
|
||||
`piercings`,
|
||||
`favorite`,
|
||||
`created_at`,
|
||||
`updated_at`,
|
||||
`details`,
|
||||
`death_date`,
|
||||
`hair_color`,
|
||||
`weight`,
|
||||
`rating`,
|
||||
`ignore_auto_tag`,
|
||||
`image_blob`,
|
||||
`penis_length`,
|
||||
`circumcised`,
|
||||
`birthdate_precision`,
|
||||
`death_date_precision`,
|
||||
CAST(`career_start` AS TEXT),
|
||||
CAST(`career_end` AS TEXT)
|
||||
FROM `performers`;
|
||||
|
||||
DROP INDEX IF EXISTS `performers_name_disambiguation_unique`;
|
||||
DROP INDEX IF EXISTS `performers_name_unique`;
|
||||
DROP TABLE `performers`;
|
||||
|
||||
ALTER TABLE `performers_new` RENAME TO `performers`;
|
||||
|
||||
UPDATE "performers" SET `career_start` = CONCAT(`career_start`, '-01-01'), "career_start_precision" = 2 WHERE "career_start" IS NOT NULL;
|
||||
UPDATE "performers" SET `career_end` = CONCAT(`career_end`, '-01-01'), "career_end_precision" = 2 WHERE "career_end" IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL;
|
||||
CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL;
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
|
@ -30,27 +30,29 @@ const (
|
|||
)
|
||||
|
||||
type performerRow struct {
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Name null.String `db:"name"` // TODO: make schema non-nullable
|
||||
Disambigation zero.String `db:"disambiguation"`
|
||||
Gender zero.String `db:"gender"`
|
||||
Birthdate NullDate `db:"birthdate"`
|
||||
BirthdatePrecision null.Int `db:"birthdate_precision"`
|
||||
Ethnicity zero.String `db:"ethnicity"`
|
||||
Country zero.String `db:"country"`
|
||||
EyeColor zero.String `db:"eye_color"`
|
||||
Height null.Int `db:"height"`
|
||||
Measurements zero.String `db:"measurements"`
|
||||
FakeTits zero.String `db:"fake_tits"`
|
||||
PenisLength null.Float `db:"penis_length"`
|
||||
Circumcised zero.String `db:"circumcised"`
|
||||
CareerStart null.Int `db:"career_start"`
|
||||
CareerEnd null.Int `db:"career_end"`
|
||||
Tattoos zero.String `db:"tattoos"`
|
||||
Piercings zero.String `db:"piercings"`
|
||||
Favorite bool `db:"favorite"`
|
||||
CreatedAt Timestamp `db:"created_at"`
|
||||
UpdatedAt Timestamp `db:"updated_at"`
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Name null.String `db:"name"` // TODO: make schema non-nullable
|
||||
Disambigation zero.String `db:"disambiguation"`
|
||||
Gender zero.String `db:"gender"`
|
||||
Birthdate NullDate `db:"birthdate"`
|
||||
BirthdatePrecision null.Int `db:"birthdate_precision"`
|
||||
Ethnicity zero.String `db:"ethnicity"`
|
||||
Country zero.String `db:"country"`
|
||||
EyeColor zero.String `db:"eye_color"`
|
||||
Height null.Int `db:"height"`
|
||||
Measurements zero.String `db:"measurements"`
|
||||
FakeTits zero.String `db:"fake_tits"`
|
||||
PenisLength null.Float `db:"penis_length"`
|
||||
Circumcised zero.String `db:"circumcised"`
|
||||
CareerStart NullDate `db:"career_start"`
|
||||
CareerStartPrecision null.Int `db:"career_start_precision"`
|
||||
CareerEnd NullDate `db:"career_end"`
|
||||
CareerEndPrecision null.Int `db:"career_end_precision"`
|
||||
Tattoos zero.String `db:"tattoos"`
|
||||
Piercings zero.String `db:"piercings"`
|
||||
Favorite bool `db:"favorite"`
|
||||
CreatedAt Timestamp `db:"created_at"`
|
||||
UpdatedAt Timestamp `db:"updated_at"`
|
||||
// expressed as 1-100
|
||||
Rating null.Int `db:"rating"`
|
||||
Details zero.String `db:"details"`
|
||||
|
|
@ -83,8 +85,10 @@ func (r *performerRow) fromPerformer(o models.Performer) {
|
|||
if o.Circumcised != nil && o.Circumcised.IsValid() {
|
||||
r.Circumcised = zero.StringFrom(o.Circumcised.String())
|
||||
}
|
||||
r.CareerStart = intFromPtr(o.CareerStart)
|
||||
r.CareerEnd = intFromPtr(o.CareerEnd)
|
||||
r.CareerStart = NullDateFromDatePtr(o.CareerStart)
|
||||
r.CareerStartPrecision = datePrecisionFromDatePtr(o.CareerStart)
|
||||
r.CareerEnd = NullDateFromDatePtr(o.CareerEnd)
|
||||
r.CareerEndPrecision = datePrecisionFromDatePtr(o.CareerEnd)
|
||||
r.Tattoos = zero.StringFrom(o.Tattoos)
|
||||
r.Piercings = zero.StringFrom(o.Piercings)
|
||||
r.Favorite = o.Favorite
|
||||
|
|
@ -112,8 +116,8 @@ func (r *performerRow) resolve() *models.Performer {
|
|||
Measurements: r.Measurements.String,
|
||||
FakeTits: r.FakeTits.String,
|
||||
PenisLength: nullFloatPtr(r.PenisLength),
|
||||
CareerStart: nullIntPtr(r.CareerStart),
|
||||
CareerEnd: nullIntPtr(r.CareerEnd),
|
||||
CareerStart: r.CareerStart.DatePtr(r.CareerStartPrecision),
|
||||
CareerEnd: r.CareerEnd.DatePtr(r.CareerEndPrecision),
|
||||
Tattoos: r.Tattoos.String,
|
||||
Piercings: r.Piercings.String,
|
||||
Favorite: r.Favorite,
|
||||
|
|
@ -134,7 +138,7 @@ func (r *performerRow) resolve() *models.Performer {
|
|||
}
|
||||
|
||||
if r.Circumcised.ValueOrZero() != "" {
|
||||
v := models.CircumisedEnum(r.Circumcised.String)
|
||||
v := models.CircumcisedEnum(r.Circumcised.String)
|
||||
ret.Circumcised = &v
|
||||
}
|
||||
|
||||
|
|
@ -158,8 +162,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) {
|
|||
r.setNullString("fake_tits", o.FakeTits)
|
||||
r.setNullFloat64("penis_length", o.PenisLength)
|
||||
r.setNullString("circumcised", o.Circumcised)
|
||||
r.setNullInt("career_start", o.CareerStart)
|
||||
r.setNullInt("career_end", o.CareerEnd)
|
||||
r.setNullDate("career_start", "career_start_precision", o.CareerStart)
|
||||
r.setNullDate("career_end", "career_end_precision", o.CareerEnd)
|
||||
r.setNullString("tattoos", o.Tattoos)
|
||||
r.setNullString("piercings", o.Piercings)
|
||||
r.setBool("favorite", o.Favorite)
|
||||
|
|
@ -778,6 +782,28 @@ func (qb *PerformerStore) sortByScenesDuration(direction string) string {
|
|||
return " ORDER BY (" + selectPerformerScenesDurationSQL + ") " + direction
|
||||
}
|
||||
|
||||
// used for sorting by total scene file size
|
||||
var selectPerformerScenesSizeSQL = utils.StrFormat(
|
||||
"SELECT COALESCE(SUM({files}.size), 0) FROM {performers_scenes} s "+
|
||||
"LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+
|
||||
"LEFT JOIN {scenes_files} ON {scenes_files}.{scene_id} = {scenes}.id "+
|
||||
"LEFT JOIN {files} ON {files}.id = {scenes_files}.file_id "+
|
||||
"WHERE s.{performer_id} = {performers}.id",
|
||||
map[string]interface{}{
|
||||
"performer_id": performerIDColumn,
|
||||
"performers": performerTable,
|
||||
"performers_scenes": performersScenesTable,
|
||||
"scenes": sceneTable,
|
||||
"scene_id": sceneIDColumn,
|
||||
"scenes_files": scenesFilesTable,
|
||||
"files": fileTable,
|
||||
},
|
||||
)
|
||||
|
||||
func (qb *PerformerStore) sortByScenesSize(direction string) string {
|
||||
return " ORDER BY (" + selectPerformerScenesSizeSQL + ") " + direction
|
||||
}
|
||||
|
||||
var performerSortOptions = sortOptions{
|
||||
"birthdate",
|
||||
"career_start",
|
||||
|
|
@ -799,6 +825,7 @@ var performerSortOptions = sortOptions{
|
|||
"rating",
|
||||
"scenes_count",
|
||||
"scenes_duration",
|
||||
"scenes_size",
|
||||
"tag_count",
|
||||
"updated_at",
|
||||
"weight",
|
||||
|
|
@ -828,6 +855,8 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s
|
|||
sortQuery += getCountSort(performerTable, performersScenesTable, performerIDColumn, direction)
|
||||
case "scenes_duration":
|
||||
sortQuery += qb.sortByScenesDuration(direction)
|
||||
case "scenes_size":
|
||||
sortQuery += qb.sortByScenesSize(direction)
|
||||
case "images_count":
|
||||
sortQuery += getCountSort(performerTable, performersImagesTable, performerIDColumn, direction)
|
||||
case "galleries_count":
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ func (qb *performerFilterHandler) validate() error {
|
|||
careerLength := filter.CareerLength
|
||||
switch careerLength.Modifier {
|
||||
case models.CriterionModifierEquals:
|
||||
start, end, err := utils.ParseYearRangeString(careerLength.Value)
|
||||
start, end, err := models.ParseYearRangeString(careerLength.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid career length value: %s", careerLength.Value)
|
||||
}
|
||||
|
|
@ -70,6 +70,28 @@ func (qb *performerFilterHandler) validate() error {
|
|||
}
|
||||
}
|
||||
|
||||
// validate date formats
|
||||
if filter.Birthdate != nil && filter.Birthdate.Value != "" {
|
||||
if _, err := models.ParseDate(filter.Birthdate.Value); err != nil {
|
||||
return fmt.Errorf("invalid birthdate value: %s", filter.Birthdate.Value)
|
||||
}
|
||||
}
|
||||
if filter.DeathDate != nil && filter.DeathDate.Value != "" {
|
||||
if _, err := models.ParseDate(filter.DeathDate.Value); err != nil {
|
||||
return fmt.Errorf("invalid death date value: %s", filter.DeathDate.Value)
|
||||
}
|
||||
}
|
||||
if filter.CareerStart != nil && filter.CareerStart.Value != "" {
|
||||
if _, err := models.ParseDate(filter.CareerStart.Value); err != nil {
|
||||
return fmt.Errorf("invalid career start value: %s", filter.CareerStart.Value)
|
||||
}
|
||||
}
|
||||
if filter.CareerEnd != nil && filter.CareerEnd.Value != "" {
|
||||
if _, err := models.ParseDate(filter.CareerEnd.Value); err != nil {
|
||||
return fmt.Errorf("invalid career end value: %s", filter.CareerEnd.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -156,8 +178,8 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
|
|||
}),
|
||||
|
||||
// CareerLength filter is deprecated and non-functional (column removed in schema 78)
|
||||
intCriterionHandler(filter.CareerStart, tableName+".career_start", nil),
|
||||
intCriterionHandler(filter.CareerEnd, tableName+".career_end", nil),
|
||||
&dateCriterionHandler{filter.CareerStart, tableName + ".career_start", nil},
|
||||
&dateCriterionHandler{filter.CareerEnd, tableName + ".career_end", nil},
|
||||
stringCriterionHandler(filter.Tattoos, tableName+".tattoos"),
|
||||
stringCriterionHandler(filter.Piercings, tableName+".piercings"),
|
||||
intCriterionHandler(filter.Rating100, tableName+".rating", nil),
|
||||
|
|
@ -266,31 +288,39 @@ func convertLegacyCareerLengthFilter(filter *models.PerformerFilterType) {
|
|||
careerLength := filter.CareerLength
|
||||
switch careerLength.Modifier {
|
||||
case models.CriterionModifierEquals:
|
||||
start, end, _ := utils.ParseYearRangeString(careerLength.Value)
|
||||
start, end, _ := models.ParseYearRangeString(careerLength.Value)
|
||||
if start != nil {
|
||||
filter.CareerStart = &models.IntCriterionInput{
|
||||
Value: (*start) - 1, // minus one to make it exclusive
|
||||
start = &models.Date{
|
||||
Time: start.AddDate(0, 0, -1), // make exclusive
|
||||
Precision: models.DatePrecisionDay,
|
||||
}
|
||||
filter.CareerStart = &models.DateCriterionInput{
|
||||
Value: start.String(),
|
||||
Modifier: models.CriterionModifierGreaterThan,
|
||||
}
|
||||
}
|
||||
if end != nil {
|
||||
filter.CareerEnd = &models.IntCriterionInput{
|
||||
Value: (*end) + 1, // plus one to make it exclusive
|
||||
end = &models.Date{
|
||||
Time: end.AddDate(1, 0, 0), // make exclusive
|
||||
Precision: models.DatePrecisionDay,
|
||||
}
|
||||
filter.CareerEnd = &models.DateCriterionInput{
|
||||
Value: end.String(), // plus one to make it exclusive
|
||||
Modifier: models.CriterionModifierLessThan,
|
||||
}
|
||||
}
|
||||
case models.CriterionModifierIsNull:
|
||||
filter.CareerStart = &models.IntCriterionInput{
|
||||
filter.CareerStart = &models.DateCriterionInput{
|
||||
Modifier: models.CriterionModifierIsNull,
|
||||
}
|
||||
filter.CareerEnd = &models.IntCriterionInput{
|
||||
filter.CareerEnd = &models.DateCriterionInput{
|
||||
Modifier: models.CriterionModifierIsNull,
|
||||
}
|
||||
case models.CriterionModifierNotNull:
|
||||
filter.CareerStart = &models.IntCriterionInput{
|
||||
filter.CareerStart = &models.DateCriterionInput{
|
||||
Modifier: models.CriterionModifierNotNull,
|
||||
}
|
||||
filter.CareerEnd = &models.IntCriterionInput{
|
||||
filter.CareerEnd = &models.DateCriterionInput{
|
||||
Modifier: models.CriterionModifierNotNull,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,9 +65,9 @@ func Test_PerformerStore_Create(t *testing.T) {
|
|||
measurements = "measurements"
|
||||
fakeTits = "fakeTits"
|
||||
penisLength = 1.23
|
||||
circumcised = models.CircumisedEnumCut
|
||||
careerStart = 2005
|
||||
careerEnd = 2015
|
||||
circumcised = models.CircumcisedEnumCut
|
||||
careerStart = models.DateFromYear(2005)
|
||||
careerEnd = models.DateFromYear(2015)
|
||||
tattoos = "tattoos"
|
||||
piercings = "piercings"
|
||||
aliases = []string{"alias1", "alias2"}
|
||||
|
|
@ -228,9 +228,9 @@ func Test_PerformerStore_Update(t *testing.T) {
|
|||
measurements = "measurements"
|
||||
fakeTits = "fakeTits"
|
||||
penisLength = 1.23
|
||||
circumcised = models.CircumisedEnumCut
|
||||
careerStart = 2005
|
||||
careerEnd = 2015
|
||||
circumcised = models.CircumcisedEnumCut
|
||||
careerStart = models.DateFromYear(2005)
|
||||
careerEnd = models.DateFromYear(2015)
|
||||
tattoos = "tattoos"
|
||||
piercings = "piercings"
|
||||
aliases = []string{"alias1", "alias2"}
|
||||
|
|
@ -424,8 +424,8 @@ func clearPerformerPartial() models.PerformerPartial {
|
|||
FakeTits: nullString,
|
||||
PenisLength: nullFloat,
|
||||
Circumcised: nullString,
|
||||
CareerStart: nullInt,
|
||||
CareerEnd: nullInt,
|
||||
CareerStart: nullDate,
|
||||
CareerEnd: nullDate,
|
||||
Tattoos: nullString,
|
||||
Piercings: nullString,
|
||||
Aliases: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},
|
||||
|
|
@ -457,9 +457,9 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
|||
measurements = "measurements"
|
||||
fakeTits = "fakeTits"
|
||||
penisLength = 1.23
|
||||
circumcised = models.CircumisedEnumCut
|
||||
careerStart = 2005
|
||||
careerEnd = 2015
|
||||
circumcised = models.CircumcisedEnumCut
|
||||
careerStart = models.DateFromYear(2005)
|
||||
careerEnd = models.DateFromYear(2015)
|
||||
tattoos = "tattoos"
|
||||
piercings = "piercings"
|
||||
aliases = []string{"alias1", "alias2"}
|
||||
|
|
@ -505,8 +505,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
|||
FakeTits: models.NewOptionalString(fakeTits),
|
||||
PenisLength: models.NewOptionalFloat64(penisLength),
|
||||
Circumcised: models.NewOptionalString(circumcised.String()),
|
||||
CareerStart: models.NewOptionalInt(careerStart),
|
||||
CareerEnd: models.NewOptionalInt(careerEnd),
|
||||
CareerStart: models.NewOptionalDate(careerStart),
|
||||
CareerEnd: models.NewOptionalDate(careerEnd),
|
||||
Tattoos: models.NewOptionalString(tattoos),
|
||||
Piercings: models.NewOptionalString(piercings),
|
||||
Aliases: &models.UpdateStrings{
|
||||
|
|
@ -1200,7 +1200,7 @@ func TestPerformerQuery(t *testing.T) {
|
|||
nil,
|
||||
&models.PerformerFilterType{
|
||||
Circumcised: &models.CircumcisionCriterionInput{
|
||||
Value: []models.CircumisedEnum{models.CircumisedEnumCut},
|
||||
Value: []models.CircumcisedEnum{models.CircumcisedEnumCut},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
|
|
@ -1213,7 +1213,7 @@ func TestPerformerQuery(t *testing.T) {
|
|||
nil,
|
||||
&models.PerformerFilterType{
|
||||
Circumcised: &models.CircumcisionCriterionInput{
|
||||
Value: []models.CircumisedEnum{models.CircumisedEnumCut},
|
||||
Value: []models.CircumcisedEnum{models.CircumcisedEnumCut},
|
||||
Modifier: models.CriterionModifierExcludes,
|
||||
},
|
||||
},
|
||||
|
|
@ -1778,8 +1778,8 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) {
|
|||
tests := []struct {
|
||||
name string
|
||||
c models.StringCriterionInput
|
||||
careerStartCrit *models.IntCriterionInput
|
||||
careerEndCrit *models.IntCriterionInput
|
||||
careerStartCrit *models.DateCriterionInput
|
||||
careerEndCrit *models.DateCriterionInput
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
|
|
@ -1788,13 +1788,13 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) {
|
|||
Value: value,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
careerStartCrit: &models.IntCriterionInput{
|
||||
Value: 2002,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
careerStartCrit: &models.DateCriterionInput{
|
||||
Value: "2001-12-31",
|
||||
Modifier: models.CriterionModifierGreaterThan,
|
||||
},
|
||||
careerEndCrit: &models.IntCriterionInput{
|
||||
Value: 2012,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
careerEndCrit: &models.DateCriterionInput{
|
||||
Value: "2013-01-01",
|
||||
Modifier: models.CriterionModifierLessThan,
|
||||
},
|
||||
err: false,
|
||||
},
|
||||
|
|
@ -1811,10 +1811,10 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) {
|
|||
c: models.StringCriterionInput{
|
||||
Modifier: models.CriterionModifierIsNull,
|
||||
},
|
||||
careerStartCrit: &models.IntCriterionInput{
|
||||
careerStartCrit: &models.DateCriterionInput{
|
||||
Modifier: models.CriterionModifierIsNull,
|
||||
},
|
||||
careerEndCrit: &models.IntCriterionInput{
|
||||
careerEndCrit: &models.DateCriterionInput{
|
||||
Modifier: models.CriterionModifierIsNull,
|
||||
},
|
||||
err: false,
|
||||
|
|
@ -1824,10 +1824,10 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) {
|
|||
c: models.StringCriterionInput{
|
||||
Modifier: models.CriterionModifierNotNull,
|
||||
},
|
||||
careerStartCrit: &models.IntCriterionInput{
|
||||
careerStartCrit: &models.DateCriterionInput{
|
||||
Modifier: models.CriterionModifierNotNull,
|
||||
},
|
||||
careerEndCrit: &models.IntCriterionInput{
|
||||
careerEndCrit: &models.DateCriterionInput{
|
||||
Modifier: models.CriterionModifierNotNull,
|
||||
},
|
||||
err: false,
|
||||
|
|
@ -1865,16 +1865,16 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, performer := range performers {
|
||||
verifyIntPtr(t, performer.CareerStart, *tt.careerStartCrit)
|
||||
verifyIntPtr(t, performer.CareerEnd, *tt.careerEndCrit)
|
||||
verifyDatePtr(t, performer.CareerStart, *tt.careerStartCrit)
|
||||
verifyDatePtr(t, performer.CareerEnd, *tt.careerEndCrit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformerQueryCareerStart(t *testing.T) {
|
||||
const value = 2002
|
||||
criterion := models.IntCriterionInput{
|
||||
const value = "2002"
|
||||
criterion := models.DateCriterionInput{
|
||||
Value: value,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}
|
||||
|
|
@ -1891,7 +1891,7 @@ func TestPerformerQueryCareerStart(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, performer := range performers {
|
||||
verifyIntPtr(t, performer.CareerStart, criterion)
|
||||
verifyDatePtr(t, performer.CareerStart, criterion)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -1899,8 +1899,8 @@ func TestPerformerQueryCareerStart(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPerformerQueryCareerEnd(t *testing.T) {
|
||||
const value = 2012
|
||||
criterion := models.IntCriterionInput{
|
||||
const value = "2012"
|
||||
criterion := models.DateCriterionInput{
|
||||
Value: value,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}
|
||||
|
|
@ -1917,7 +1917,7 @@ func TestPerformerQueryCareerEnd(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, performer := range performers {
|
||||
verifyIntPtr(t, performer.CareerEnd, criterion)
|
||||
verifyDatePtr(t, performer.CareerEnd, criterion)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -2821,6 +2821,33 @@ func verifyIntPtr(t *testing.T, value *int, criterion models.IntCriterionInput)
|
|||
}
|
||||
}
|
||||
|
||||
func verifyDatePtr(t *testing.T, value *models.Date, criterion models.DateCriterionInput) {
|
||||
t.Helper()
|
||||
assert := assert.New(t)
|
||||
if criterion.Modifier == models.CriterionModifierIsNull {
|
||||
assert.Nil(value, "expect is null values to be null")
|
||||
}
|
||||
if criterion.Modifier == models.CriterionModifierNotNull {
|
||||
assert.NotNil(value, "expect not null values to be not null")
|
||||
}
|
||||
if criterion.Modifier == models.CriterionModifierEquals {
|
||||
date, _ := models.ParseDate(criterion.Value)
|
||||
assert.Equal(date, *value)
|
||||
}
|
||||
if criterion.Modifier == models.CriterionModifierNotEquals {
|
||||
date, _ := models.ParseDate(criterion.Value)
|
||||
assert.NotEqual(date, *value)
|
||||
}
|
||||
if criterion.Modifier == models.CriterionModifierGreaterThan {
|
||||
date, _ := models.ParseDate(criterion.Value)
|
||||
assert.True(value.After(date))
|
||||
}
|
||||
if criterion.Modifier == models.CriterionModifierLessThan {
|
||||
date, _ := models.ParseDate(criterion.Value)
|
||||
assert.True(date.After(*value))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneQueryOCounter(t *testing.T) {
|
||||
const oCounter = 1
|
||||
oCounterCriterion := models.IntCriterionInput{
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@ const (
|
|||
pathField = "Path"
|
||||
checksumField = "Checksum"
|
||||
titleField = "Title"
|
||||
detailsField = "Details"
|
||||
urlField = "URL"
|
||||
zipPath = "zipPath.zip"
|
||||
firstSavedFilterName = "firstSavedFilterName"
|
||||
|
|
@ -865,16 +866,24 @@ func getFileModTime(index int) time.Time {
|
|||
return getFolderModTime(index)
|
||||
}
|
||||
|
||||
func getFilePhash(index int) int64 {
|
||||
return int64(index * 567)
|
||||
}
|
||||
|
||||
func getFileFingerprints(index int) []models.Fingerprint {
|
||||
return []models.Fingerprint{
|
||||
{
|
||||
Type: "MD5",
|
||||
Type: models.FingerprintTypeMD5,
|
||||
Fingerprint: getPrefixedStringValue("file", index, "md5"),
|
||||
},
|
||||
{
|
||||
Type: "OSHASH",
|
||||
Type: models.FingerprintTypeOshash,
|
||||
Fingerprint: getPrefixedStringValue("file", index, "oshash"),
|
||||
},
|
||||
{
|
||||
Type: models.FingerprintTypePhash,
|
||||
Fingerprint: getFilePhash(index),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1298,9 +1307,10 @@ func makeImage(i int) *models.Image {
|
|||
tids := indexesToIDs(tagIDs, imageTags[i])
|
||||
|
||||
return &models.Image{
|
||||
Title: title,
|
||||
Rating: getIntPtr(getRating(i)),
|
||||
Date: getObjectDate(i),
|
||||
Title: title,
|
||||
Details: getImageStringValue(i, detailsField),
|
||||
Rating: getIntPtr(getRating(i)),
|
||||
Date: getObjectDate(i),
|
||||
URLs: models.NewRelatedStrings([]string{
|
||||
getImageEmptyString(i, urlField),
|
||||
}),
|
||||
|
|
@ -1588,24 +1598,24 @@ func getPerformerDeathDate(index int) *models.Date {
|
|||
return &ret
|
||||
}
|
||||
|
||||
func getPerformerCareerStart(index int) *int {
|
||||
func getPerformerCareerStart(index int) *models.Date {
|
||||
if index%5 == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ret := 2000 + index
|
||||
return &ret
|
||||
date := models.DateFromYear(2000 + index)
|
||||
return &date
|
||||
}
|
||||
|
||||
func getPerformerCareerEnd(index int) *int {
|
||||
func getPerformerCareerEnd(index int) *models.Date {
|
||||
if index%5 == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// only set career_end for even indices
|
||||
if index%2 == 0 {
|
||||
ret := 2010 + index
|
||||
return &ret
|
||||
date := models.DateFromYear(2010 + index)
|
||||
return &date
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1619,15 +1629,15 @@ func getPerformerPenisLength(index int) *float64 {
|
|||
return &ret
|
||||
}
|
||||
|
||||
func getPerformerCircumcised(index int) *models.CircumisedEnum {
|
||||
var ret models.CircumisedEnum
|
||||
func getPerformerCircumcised(index int) *models.CircumcisedEnum {
|
||||
var ret models.CircumcisedEnum
|
||||
switch {
|
||||
case index%3 == 0:
|
||||
return nil
|
||||
case index%3 == 1:
|
||||
ret = models.CircumisedEnumCut
|
||||
ret = models.CircumcisedEnumCut
|
||||
default:
|
||||
ret = models.CircumisedEnumUncut
|
||||
ret = models.CircumcisedEnumUncut
|
||||
}
|
||||
|
||||
return &ret
|
||||
|
|
|
|||
|
|
@ -269,8 +269,11 @@ func getDateWhereClause(column string, modifier models.CriterionModifier, value
|
|||
upper = &u
|
||||
}
|
||||
|
||||
args := []interface{}{value}
|
||||
betweenArgs := []interface{}{value, *upper}
|
||||
valueDate, _ := models.ParseDate(value)
|
||||
date := Date{Date: valueDate.Time}
|
||||
|
||||
args := []interface{}{date}
|
||||
betweenArgs := []interface{}{date, *upper}
|
||||
|
||||
switch modifier {
|
||||
case models.CriterionModifierIsNull:
|
||||
|
|
|
|||
|
|
@ -629,6 +629,16 @@ func (qb *StudioStore) sortByScenesDuration(direction string) string {
|
|||
) %s`, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction))
|
||||
}
|
||||
|
||||
func (qb *StudioStore) sortByScenesSize(direction string) string {
|
||||
return fmt.Sprintf(` ORDER BY (
|
||||
SELECT COALESCE(SUM(%s.size), 0)
|
||||
FROM %s
|
||||
LEFT JOIN %s ON %s.%s = %s.id
|
||||
LEFT JOIN %s ON %s.id = %s.file_id
|
||||
WHERE %s.%s = %s.id
|
||||
) %s`, fileTable, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction))
|
||||
}
|
||||
|
||||
// used for sorting on performer latest scene
|
||||
var selectStudioLatestSceneSQL = utils.StrFormat(
|
||||
"SELECT MAX(date) FROM ("+
|
||||
|
|
@ -658,6 +668,7 @@ var studioSortOptions = sortOptions{
|
|||
"name",
|
||||
"scenes_count",
|
||||
"scenes_duration",
|
||||
"scenes_size",
|
||||
"random",
|
||||
"rating",
|
||||
"tag_count",
|
||||
|
|
@ -688,6 +699,8 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string,
|
|||
sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction)
|
||||
case "scenes_duration":
|
||||
sortQuery += qb.sortByScenesDuration(direction)
|
||||
case "scenes_size":
|
||||
sortQuery += qb.sortByScenesSize(direction)
|
||||
case "images_count":
|
||||
sortQuery += getCountSort(studioTable, imageTable, studioIDColumn, direction)
|
||||
case "galleries_count":
|
||||
|
|
|
|||
|
|
@ -770,6 +770,7 @@ var tagSortOptions = sortOptions{
|
|||
"scene_markers_count",
|
||||
"scenes_count",
|
||||
"scenes_duration",
|
||||
"scenes_size",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
|
|
@ -784,6 +785,17 @@ func (qb *TagStore) sortByScenesDuration(direction string) string {
|
|||
) %s`, scenesTagsTable, sceneTable, sceneTable, scenesTagsTable, sceneIDColumn, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, scenesFilesTable, scenesTagsTable, tagIDColumn, tagTable, getSortDirection(direction))
|
||||
}
|
||||
|
||||
func (qb *TagStore) sortByScenesSize(direction string) string {
|
||||
return fmt.Sprintf(` ORDER BY (
|
||||
SELECT COALESCE(SUM(%s.size), 0)
|
||||
FROM %s
|
||||
LEFT JOIN %s ON %s.id = %s.%s
|
||||
LEFT JOIN %s ON %s.%s = %s.id
|
||||
LEFT JOIN %s ON %s.id = %s.file_id
|
||||
WHERE %s.%s = %s.id
|
||||
) %s`, fileTable, scenesTagsTable, sceneTable, sceneTable, scenesTagsTable, sceneIDColumn, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, scenesTagsTable, tagIDColumn, tagTable, getSortDirection(direction))
|
||||
}
|
||||
|
||||
func (qb *TagStore) getDefaultTagSort() string {
|
||||
return getSort("name", "ASC", "tags")
|
||||
}
|
||||
|
|
@ -812,6 +824,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
|
|||
sortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction)
|
||||
case "scenes_duration":
|
||||
sortQuery += qb.sortByScenesDuration(direction)
|
||||
case "scenes_size":
|
||||
sortQuery += qb.sortByScenesSize(direction)
|
||||
case "scene_markers_count":
|
||||
sortQuery += fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s", getSortDirection(direction))
|
||||
case "images_count":
|
||||
|
|
|
|||
|
|
@ -128,10 +128,11 @@ func (t *StudioFragment) GetImages() []*ImageFragment {
|
|||
}
|
||||
|
||||
type TagFragment struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
Aliases []string "json:\"aliases\" graphql:\"aliases\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
Aliases []string "json:\"aliases\" graphql:\"aliases\""
|
||||
Category *TagFragment_Category "json:\"category,omitempty\" graphql:\"category\""
|
||||
}
|
||||
|
||||
func (t *TagFragment) GetName() string {
|
||||
|
|
@ -158,6 +159,12 @@ func (t *TagFragment) GetAliases() []string {
|
|||
}
|
||||
return t.Aliases
|
||||
}
|
||||
func (t *TagFragment) GetCategory() *TagFragment_Category {
|
||||
if t == nil {
|
||||
t = &TagFragment{}
|
||||
}
|
||||
return t.Category
|
||||
}
|
||||
|
||||
type MeasurementsFragment struct {
|
||||
BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\""
|
||||
|
|
@ -530,6 +537,31 @@ func (t *StudioFragment_Parent) GetName() string {
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -548,6 +580,31 @@ func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type SceneFragment_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *SceneFragment_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *SceneFragment_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -566,6 +623,31 @@ func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragme
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -584,6 +666,31 @@ func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) Get
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -602,6 +709,31 @@ func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) Get
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindStudio_FindStudio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -620,6 +752,56 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type FindTag_FindTag_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindTag_FindTag_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &FindTag_FindTag_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *FindTag_FindTag_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindTag_FindTag_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *FindTag_FindTag_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindTag_FindTag_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type QueryTags_QueryTags_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &QueryTags_QueryTags_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &QueryTags_QueryTags_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &QueryTags_QueryTags_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type QueryTags_QueryTags struct {
|
||||
Count int "json:\"count\" graphql:\"count\""
|
||||
Tags []*TagFragment "json:\"tags\" graphql:\"tags\""
|
||||
|
|
@ -865,6 +1047,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
|
|
@ -1003,6 +1190,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
|
|
@ -1299,6 +1491,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
|
|
@ -1435,6 +1632,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -1469,6 +1671,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -232,12 +232,12 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc
|
|||
}
|
||||
|
||||
if p.CareerStartYear != nil {
|
||||
cs := *p.CareerStartYear
|
||||
cs := strconv.Itoa(*p.CareerStartYear)
|
||||
sp.CareerStart = &cs
|
||||
}
|
||||
|
||||
if p.CareerEndYear != nil {
|
||||
ce := *p.CareerEndYear
|
||||
ce := strconv.Itoa(*p.CareerEndYear)
|
||||
sp.CareerEnd = &ce
|
||||
}
|
||||
|
||||
|
|
@ -399,10 +399,12 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf
|
|||
draft.Aliases = &aliases
|
||||
}
|
||||
if performer.CareerStart != nil {
|
||||
draft.CareerStartYear = performer.CareerStart
|
||||
year := performer.CareerStart.Year()
|
||||
draft.CareerStartYear = &year
|
||||
}
|
||||
if performer.CareerEnd != nil {
|
||||
draft.CareerEndYear = performer.CareerEnd
|
||||
year := performer.CareerEnd.Year()
|
||||
draft.CareerEndYear = &year
|
||||
}
|
||||
|
||||
if len(performer.URLs.List()) > 0 {
|
||||
|
|
|
|||
|
|
@ -72,5 +72,12 @@ func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag {
|
|||
ret.AliasList = t.Aliases
|
||||
}
|
||||
|
||||
if t.Category != nil {
|
||||
ret.Parent = &models.ScrapedTag{
|
||||
Name: t.Category.Name,
|
||||
Description: t.Category.Description,
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package utils
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -27,80 +25,3 @@ func ParseDateStringAsTime(dateString string) (time.Time, error) {
|
|||
|
||||
return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString)
|
||||
}
|
||||
|
||||
// ParseYearRangeString parses a year range string into start and end year integers.
|
||||
// Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present".
|
||||
// Returns nil for start/end if not present in the string.
|
||||
func ParseYearRangeString(s string) (start *int, end *int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, nil, fmt.Errorf("empty year range string")
|
||||
}
|
||||
|
||||
// normalize "present" to empty end
|
||||
lower := strings.ToLower(s)
|
||||
lower = strings.ReplaceAll(lower, "present", "")
|
||||
|
||||
// split on "-" if it contains one
|
||||
var parts []string
|
||||
if strings.Contains(lower, "-") {
|
||||
parts = strings.SplitN(lower, "-", 2)
|
||||
} else {
|
||||
// single value, treat as start year
|
||||
year, err := parseYear(lower)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err)
|
||||
}
|
||||
return &year, nil, nil
|
||||
}
|
||||
|
||||
startStr := strings.TrimSpace(parts[0])
|
||||
endStr := strings.TrimSpace(parts[1])
|
||||
|
||||
if startStr != "" {
|
||||
y, err := parseYear(startStr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err)
|
||||
}
|
||||
start = &y
|
||||
}
|
||||
|
||||
if endStr != "" {
|
||||
y, err := parseYear(endStr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err)
|
||||
}
|
||||
end = &y
|
||||
}
|
||||
|
||||
if start == nil && end == nil {
|
||||
return nil, nil, fmt.Errorf("could not parse year range %q", s)
|
||||
}
|
||||
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func parseYear(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
year, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid year %q: %w", s, err)
|
||||
}
|
||||
if year < 1900 || year > 2200 {
|
||||
return 0, fmt.Errorf("year %d out of reasonable range", year)
|
||||
}
|
||||
return year, nil
|
||||
}
|
||||
|
||||
func FormatYearRange(start *int, end *int) string {
|
||||
switch {
|
||||
case start == nil && end == nil:
|
||||
return ""
|
||||
case end == nil:
|
||||
return fmt.Sprintf("%d -", *start)
|
||||
case start == nil:
|
||||
return fmt.Sprintf("- %d", *end)
|
||||
default:
|
||||
return fmt.Sprintf("%d - %d", *start, *end)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package utils
|
|||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseDateStringAsTime(t *testing.T) {
|
||||
|
|
@ -43,66 +41,3 @@ func TestParseDateStringAsTime(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYearRangeString(t *testing.T) {
|
||||
intPtr := func(v int) *int { return &v }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantStart *int
|
||||
wantEnd *int
|
||||
wantErr bool
|
||||
}{
|
||||
{"single year", "2005", intPtr(2005), nil, false},
|
||||
{"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false},
|
||||
{"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false},
|
||||
{"year dash open", "2005 -", intPtr(2005), nil, false},
|
||||
{"year dash open no space", "2005-", intPtr(2005), nil, false},
|
||||
{"dash year", "- 2010", nil, intPtr(2010), false},
|
||||
{"year present", "2005-present", intPtr(2005), nil, false},
|
||||
{"year Present caps", "2005 - Present", intPtr(2005), nil, false},
|
||||
{"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false},
|
||||
{"empty string", "", nil, nil, true},
|
||||
{"garbage", "not a year", nil, nil, true},
|
||||
{"partial garbage start", "abc - 2010", nil, nil, true},
|
||||
{"partial garbage end", "2005 - abc", nil, nil, true},
|
||||
{"year out of range", "1800", nil, nil, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, end, err := ParseYearRangeString(tt.input)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantStart, start)
|
||||
assert.Equal(t, tt.wantEnd, end)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatYearRange(t *testing.T) {
|
||||
intPtr := func(v int) *int { return &v }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start *int
|
||||
end *int
|
||||
want string
|
||||
}{
|
||||
{"both nil", nil, nil, ""},
|
||||
{"only start", intPtr(2005), nil, "2005 -"},
|
||||
{"only end", nil, intPtr(2010), "- 2010"},
|
||||
{"start and end", intPtr(2005), intPtr(2010), "2005 - 2010"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatYearRange(tt.start, tt.end)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,11 @@ fragment ScrapedSceneTagData on ScrapedTag {
|
|||
name
|
||||
description
|
||||
alias_list
|
||||
parent {
|
||||
stored_id
|
||||
name
|
||||
description
|
||||
}
|
||||
remote_site_id
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import V0270 from "src/docs/en/Changelog/v0270.md";
|
|||
import V0280 from "src/docs/en/Changelog/v0280.md";
|
||||
import V0290 from "src/docs/en/Changelog/v0290.md";
|
||||
import V0300 from "src/docs/en/Changelog/v0300.md";
|
||||
import V0310 from "src/docs/en/Changelog/v0310.md";
|
||||
|
||||
import V0290ReleaseNotes from "src/docs/en/ReleaseNotes/v0290.md";
|
||||
|
||||
|
|
@ -75,9 +76,9 @@ const Changelog: React.FC = () => {
|
|||
// after new release:
|
||||
// add entry to releases, using the current* fields
|
||||
// then update the current fields.
|
||||
const currentVersion = stashVersion || "v0.30.0";
|
||||
const currentVersion = stashVersion || "v0.31.0";
|
||||
const currentDate = buildDate;
|
||||
const currentPage = V0300;
|
||||
const currentPage = V0310;
|
||||
|
||||
const releases: IStashRelease[] = [
|
||||
{
|
||||
|
|
@ -86,6 +87,12 @@ const Changelog: React.FC = () => {
|
|||
page: currentPage,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
version: "v0.30.1",
|
||||
date: "2025-12-18",
|
||||
page: V0300,
|
||||
releaseNotes: V0290ReleaseNotes,
|
||||
},
|
||||
{
|
||||
version: "v0.29.3",
|
||||
date: "2025-11-06",
|
||||
|
|
|
|||
|
|
@ -20,11 +20,6 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
&-version {
|
||||
&-body {
|
||||
padding: 1rem 2rem;
|
||||
|
|
|
|||
|
|
@ -1,100 +1,129 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Col, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useBulkGalleryUpdate } from "src/core/StashService";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StudioSelect } from "../Shared/Select";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import { MultiSet } from "../Shared/MultiSet";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { RatingSystem } from "../Shared/Rating/RatingSystem";
|
||||
import {
|
||||
getAggregateInputIDs,
|
||||
getAggregateInputValue,
|
||||
getAggregatePerformerIds,
|
||||
getAggregateRating,
|
||||
getAggregateStudioId,
|
||||
getAggregateStateObject,
|
||||
getAggregateTagIds,
|
||||
getAggregateStudioId,
|
||||
getAggregateSceneIds,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
|
||||
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
|
||||
import { BulkUpdateDateInput } from "../Shared/DateInput";
|
||||
import { getDateError } from "src/utils/yup";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimGalleryDataFragment[];
|
||||
onClose: (applied: boolean) => void;
|
||||
}
|
||||
|
||||
const galleryFields = [
|
||||
"code",
|
||||
"rating100",
|
||||
"details",
|
||||
"organized",
|
||||
"photographer",
|
||||
"date",
|
||||
];
|
||||
|
||||
export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
||||
props: IListOperationProps
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const [rating100, setRating] = useState<number>();
|
||||
const [studioId, setStudioId] = useState<string>();
|
||||
const [performerMode, setPerformerMode] =
|
||||
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
|
||||
const [performerIds, setPerformerIds] = useState<string[]>();
|
||||
const [existingPerformerIds, setExistingPerformerIds] = useState<string[]>();
|
||||
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
|
||||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [existingTagIds, setExistingTagIds] = useState<string[]>();
|
||||
const [organized, setOrganized] = useState<boolean | undefined>();
|
||||
|
||||
const [updateInput, setUpdateInput] = useState<GQL.BulkGalleryUpdateInput>({
|
||||
ids: props.selected.map((gallery) => {
|
||||
return gallery.id;
|
||||
}),
|
||||
});
|
||||
|
||||
const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
const [sceneIds, setSceneIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
|
||||
const unsetDisabled = props.selected.length < 2;
|
||||
|
||||
const [dateError, setDateError] = useState<string | undefined>();
|
||||
|
||||
const [updateGalleries] = useBulkGalleryUpdate();
|
||||
|
||||
// Network state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||
const aggregateState = useMemo(() => {
|
||||
const updateState: Partial<GQL.BulkGalleryUpdateInput> = {};
|
||||
const state = props.selected;
|
||||
updateState.studio_id = getAggregateStudioId(props.selected);
|
||||
const updateTagIds = getAggregateTagIds(props.selected);
|
||||
const updatePerformerIds = getAggregatePerformerIds(props.selected);
|
||||
const updateSceneIds = getAggregateSceneIds(props.selected);
|
||||
let first = true;
|
||||
|
||||
state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
|
||||
getAggregateStateObject(updateState, gallery, galleryFields, first);
|
||||
first = false;
|
||||
});
|
||||
|
||||
return {
|
||||
state: updateState,
|
||||
tagIds: updateTagIds,
|
||||
performerIds: updatePerformerIds,
|
||||
sceneIds: updateSceneIds,
|
||||
};
|
||||
}, [props.selected]);
|
||||
|
||||
// update initial state from aggregate
|
||||
useEffect(() => {
|
||||
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
|
||||
}, [aggregateState]);
|
||||
|
||||
useEffect(() => {
|
||||
setDateError(getDateError(updateInput.date ?? "", intl));
|
||||
}, [updateInput.date, intl]);
|
||||
|
||||
function setUpdateField(input: Partial<GQL.BulkGalleryUpdateInput>) {
|
||||
setUpdateInput((current) => ({ ...current, ...input }));
|
||||
}
|
||||
|
||||
function getGalleryInput(): GQL.BulkGalleryUpdateInput {
|
||||
// need to determine what we are actually setting on each gallery
|
||||
const aggregateRating = getAggregateRating(props.selected);
|
||||
const aggregateStudioId = getAggregateStudioId(props.selected);
|
||||
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
|
||||
const aggregateTagIds = getAggregateTagIds(props.selected);
|
||||
|
||||
const galleryInput: GQL.BulkGalleryUpdateInput = {
|
||||
ids: props.selected.map((gallery) => {
|
||||
return gallery.id;
|
||||
}),
|
||||
...updateInput,
|
||||
tag_ids: tagIds,
|
||||
performer_ids: performerIds,
|
||||
scene_ids: sceneIds,
|
||||
};
|
||||
|
||||
galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
|
||||
galleryInput.studio_id = getAggregateInputValue(
|
||||
studioId,
|
||||
aggregateStudioId
|
||||
// we don't have unset functionality for the rating star control
|
||||
// so need to determine if we are setting a rating or not
|
||||
galleryInput.rating100 = getAggregateInputValue(
|
||||
updateInput.rating100,
|
||||
aggregateState.state.rating100
|
||||
);
|
||||
|
||||
galleryInput.performer_ids = getAggregateInputIDs(
|
||||
performerMode,
|
||||
performerIds,
|
||||
aggregatePerformerIds
|
||||
);
|
||||
galleryInput.tag_ids = getAggregateInputIDs(
|
||||
tagMode,
|
||||
tagIds,
|
||||
aggregateTagIds
|
||||
);
|
||||
|
||||
if (organized !== undefined) {
|
||||
galleryInput.organized = organized;
|
||||
}
|
||||
|
||||
return galleryInput;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateGalleries({
|
||||
variables: {
|
||||
input: getGalleryInput(),
|
||||
},
|
||||
});
|
||||
await updateGalleries({ variables: { input: getGalleryInput() } });
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
|
|
@ -110,129 +139,13 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
setIsUpdating(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const state = props.selected;
|
||||
let updateRating: number | undefined;
|
||||
let updateStudioID: string | undefined;
|
||||
let updatePerformerIds: string[] = [];
|
||||
let updateTagIds: string[] = [];
|
||||
let updateOrganized: boolean | undefined;
|
||||
let first = true;
|
||||
|
||||
state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
|
||||
const galleryRating = gallery.rating100;
|
||||
const GalleriestudioID = gallery?.studio?.id;
|
||||
const galleryPerformerIDs = (gallery.performers ?? [])
|
||||
.map((p) => p.id)
|
||||
.sort();
|
||||
const galleryTagIDs = (gallery.tags ?? []).map((p) => p.id).sort();
|
||||
|
||||
if (first) {
|
||||
updateRating = galleryRating ?? undefined;
|
||||
updateStudioID = GalleriestudioID;
|
||||
updatePerformerIds = galleryPerformerIDs;
|
||||
updateTagIds = galleryTagIDs;
|
||||
updateOrganized = gallery.organized;
|
||||
first = false;
|
||||
} else {
|
||||
if (galleryRating !== updateRating) {
|
||||
updateRating = undefined;
|
||||
}
|
||||
if (GalleriestudioID !== updateStudioID) {
|
||||
updateStudioID = undefined;
|
||||
}
|
||||
if (!isEqual(galleryPerformerIDs, updatePerformerIds)) {
|
||||
updatePerformerIds = [];
|
||||
}
|
||||
if (!isEqual(galleryTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
if (gallery.organized !== updateOrganized) {
|
||||
updateOrganized = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setRating(updateRating);
|
||||
setStudioId(updateStudioID);
|
||||
setExistingPerformerIds(updatePerformerIds);
|
||||
setExistingTagIds(updateTagIds);
|
||||
|
||||
setOrganized(updateOrganized);
|
||||
}, [props.selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = organized === undefined;
|
||||
}
|
||||
}, [organized, checkboxRef]);
|
||||
|
||||
function renderMultiSelect(
|
||||
type: "performers" | "tags",
|
||||
ids: string[] | undefined
|
||||
) {
|
||||
let mode = GQL.BulkUpdateIdMode.Add;
|
||||
let existingIds: string[] | undefined = [];
|
||||
switch (type) {
|
||||
case "performers":
|
||||
mode = performerMode;
|
||||
existingIds = existingPerformerIds;
|
||||
break;
|
||||
case "tags":
|
||||
mode = tagMode;
|
||||
existingIds = existingTagIds;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiSet
|
||||
type={type}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => {
|
||||
switch (type) {
|
||||
case "performers":
|
||||
setPerformerIds(itemIDs);
|
||||
break;
|
||||
case "tags":
|
||||
setTagIds(itemIDs);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
switch (type) {
|
||||
case "performers":
|
||||
setPerformerMode(newMode);
|
||||
break;
|
||||
case "tags":
|
||||
setTagMode(newMode);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
existingIds={existingIds ?? []}
|
||||
ids={ids ?? []}
|
||||
mode={mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function cycleOrganized() {
|
||||
if (organized) {
|
||||
setOrganized(undefined);
|
||||
} else if (organized === undefined) {
|
||||
setOrganized(false);
|
||||
} else {
|
||||
setOrganized(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "dialogs.edit_entity_title" },
|
||||
{ id: "dialogs.edit_entity_count_title" },
|
||||
{
|
||||
count: props?.selected?.length ?? 1,
|
||||
singularEntity: intl.formatMessage({ id: "gallery" }),
|
||||
|
|
@ -243,6 +156,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
onClick: onSave,
|
||||
text: intl.formatMessage({ id: "actions.apply" }),
|
||||
}}
|
||||
disabled={isUpdating || !!dateError}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(false),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
|
|
@ -251,55 +165,119 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
isRunning={isUpdating}
|
||||
>
|
||||
<Form>
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<RatingSystem
|
||||
value={rating100}
|
||||
onSetRating={(value) => setRating(value ?? undefined)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "studio" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
||||
}
|
||||
ids={studioId ? [studioId] : []}
|
||||
isDisabled={isUpdating}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<BulkUpdateFormGroup name="rating">
|
||||
<RatingSystem
|
||||
value={updateInput.rating100}
|
||||
onSetRating={(value) =>
|
||||
setUpdateField({ rating100: value ?? undefined })
|
||||
}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="performers">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="performers" />
|
||||
</Form.Label>
|
||||
{renderMultiSelect("performers", performerIds)}
|
||||
</Form.Group>
|
||||
<BulkUpdateFormGroup name="scene_code">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.code}
|
||||
valueChanged={(newValue) => setUpdateField({ code: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="date">
|
||||
<BulkUpdateDateInput
|
||||
value={updateInput.date}
|
||||
valueChanged={(newValue) => setUpdateField({ date: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
error={dateError}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="tags" />
|
||||
</Form.Label>
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</Form.Group>
|
||||
<BulkUpdateFormGroup name="photographer">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.photographer}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ photographer: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="studio">
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setUpdateField({
|
||||
studio_id: items.length > 0 ? items[0]?.id : undefined,
|
||||
})
|
||||
}
|
||||
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
|
||||
isDisabled={isUpdating}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="performers" inline={false}>
|
||||
<MultiSet
|
||||
type={"performers"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => {
|
||||
setPerformerIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setPerformerIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={performerIds.ids ?? []}
|
||||
existingIds={aggregateState.performerIds}
|
||||
mode={performerIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="scenes" inline={false}>
|
||||
<MultiSet
|
||||
type={"scenes"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => {
|
||||
setSceneIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setSceneIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={sceneIds.ids ?? []}
|
||||
existingIds={aggregateState.sceneIds}
|
||||
mode={sceneIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="tags" inline={false}>
|
||||
<MultiSet
|
||||
type={"tags"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => {
|
||||
setTagIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setTagIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={tagIds.ids ?? []}
|
||||
existingIds={aggregateState.tagIds}
|
||||
mode={tagIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="details" inline={false}>
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.details}
|
||||
valueChanged={(newValue) => setUpdateField({ details: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
as="textarea"
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="organized">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
<IndeterminateCheckbox
|
||||
label={intl.formatMessage({ id: "organized" })}
|
||||
checked={organized}
|
||||
ref={checkboxRef}
|
||||
onChange={() => cycleOrganized()}
|
||||
setChecked={(checked) => setUpdateField({ organized: checked })}
|
||||
checked={updateInput.organized ?? undefined}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
|
|
@ -24,40 +24,43 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = PatchComponent(
|
|||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
|
||||
function filterHook(filter: ListFilterModel) {
|
||||
const galleryValue = {
|
||||
id: gallery.id,
|
||||
label: galleryTitle(gallery),
|
||||
};
|
||||
// if galleries is already present, then we modify it, otherwise add
|
||||
let galleryCriterion = filter.criteria.find((c) => {
|
||||
return c.criterionOption.type === "galleries";
|
||||
}) as GalleriesCriterion | undefined;
|
||||
const filterHook = useCallback(
|
||||
(filter: ListFilterModel) => {
|
||||
const galleryValue = {
|
||||
id: gallery.id,
|
||||
label: galleryTitle(gallery),
|
||||
};
|
||||
// if galleries is already present, then we modify it, otherwise add
|
||||
let galleryCriterion = filter.criteria.find((c) => {
|
||||
return c.criterionOption.type === "galleries";
|
||||
}) as GalleriesCriterion | undefined;
|
||||
|
||||
if (
|
||||
galleryCriterion &&
|
||||
galleryCriterion.modifier === GQL.CriterionModifier.Excludes
|
||||
) {
|
||||
// add the gallery if not present
|
||||
if (
|
||||
!galleryCriterion.value.find((p) => {
|
||||
return p.id === gallery.id;
|
||||
})
|
||||
galleryCriterion &&
|
||||
galleryCriterion.modifier === GQL.CriterionModifier.Excludes
|
||||
) {
|
||||
galleryCriterion.value.push(galleryValue);
|
||||
// add the gallery if not present
|
||||
if (
|
||||
!galleryCriterion.value.find((p) => {
|
||||
return p.id === gallery.id;
|
||||
})
|
||||
) {
|
||||
galleryCriterion.value.push(galleryValue);
|
||||
}
|
||||
|
||||
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
|
||||
} else {
|
||||
// overwrite
|
||||
galleryCriterion = new GalleriesCriterion();
|
||||
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
|
||||
galleryCriterion.value = [galleryValue];
|
||||
filter.criteria.push(galleryCriterion);
|
||||
}
|
||||
|
||||
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
|
||||
} else {
|
||||
// overwrite
|
||||
galleryCriterion = new GalleriesCriterion();
|
||||
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
|
||||
galleryCriterion.value = [galleryValue];
|
||||
filter.criteria.push(galleryCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
return filter;
|
||||
},
|
||||
[gallery]
|
||||
);
|
||||
|
||||
async function addImages(
|
||||
result: GQL.FindImagesQueryResult,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
|
|
@ -32,40 +32,43 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
|
|||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
|
||||
function filterHook(filter: ListFilterModel) {
|
||||
const galleryValue = {
|
||||
id: gallery.id!,
|
||||
label: galleryTitle(gallery),
|
||||
};
|
||||
// if galleries is already present, then we modify it, otherwise add
|
||||
let galleryCriterion = filter.criteria.find((c) => {
|
||||
return c.criterionOption.type === "galleries";
|
||||
}) as GalleriesCriterion | undefined;
|
||||
const filterHook = useCallback(
|
||||
(filter: ListFilterModel) => {
|
||||
const galleryValue = {
|
||||
id: gallery.id!,
|
||||
label: galleryTitle(gallery),
|
||||
};
|
||||
// if galleries is already present, then we modify it, otherwise add
|
||||
let galleryCriterion = filter.criteria.find((c) => {
|
||||
return c.criterionOption.type === "galleries";
|
||||
}) as GalleriesCriterion | undefined;
|
||||
|
||||
if (
|
||||
galleryCriterion &&
|
||||
(galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
galleryCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||
) {
|
||||
// add the gallery if not present
|
||||
if (
|
||||
!galleryCriterion.value.find((p) => {
|
||||
return p.id === gallery.id;
|
||||
})
|
||||
galleryCriterion &&
|
||||
(galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
galleryCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||
) {
|
||||
galleryCriterion.value.push(galleryValue);
|
||||
// add the gallery if not present
|
||||
if (
|
||||
!galleryCriterion.value.find((p) => {
|
||||
return p.id === gallery.id;
|
||||
})
|
||||
) {
|
||||
galleryCriterion.value.push(galleryValue);
|
||||
}
|
||||
|
||||
galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
galleryCriterion = new GalleriesCriterion();
|
||||
galleryCriterion.value = [galleryValue];
|
||||
filter.criteria.push(galleryCriterion);
|
||||
}
|
||||
|
||||
galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
galleryCriterion = new GalleriesCriterion();
|
||||
galleryCriterion.value = [galleryValue];
|
||||
filter.criteria.push(galleryCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
return filter;
|
||||
},
|
||||
[gallery]
|
||||
);
|
||||
|
||||
async function setCover(
|
||||
result: GQL.FindImagesQueryResult,
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Col, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useBulkGroupUpdate } from "src/core/StashService";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { StudioSelect } from "../Shared/Select";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { MultiSet } from "../Shared/MultiSet";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import { RatingSystem } from "../Shared/Rating/RatingSystem";
|
||||
import {
|
||||
getAggregateIds,
|
||||
getAggregateInputIDs,
|
||||
getAggregateInputValue,
|
||||
getAggregateRating,
|
||||
getAggregateStudioId,
|
||||
getAggregateStateObject,
|
||||
getAggregateTagIds,
|
||||
getAggregateStudioId,
|
||||
getAggregateIds,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { MultiSet } from "../Shared/MultiSet";
|
||||
import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet";
|
||||
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
|
||||
import { BulkUpdateDateInput } from "../Shared/DateInput";
|
||||
import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable";
|
||||
import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet";
|
||||
import { getDateError } from "src/utils/yup";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.ListGroupDataFragment[];
|
||||
|
|
@ -67,50 +67,86 @@ function getAggregateContainingGroupInput(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const groupFields = ["rating100", "synopsis", "director", "date"];
|
||||
|
||||
export const EditGroupsDialog: React.FC<IListOperationProps> = (
|
||||
props: IListOperationProps
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const [rating100, setRating] = useState<number | undefined>();
|
||||
const [studioId, setStudioId] = useState<string | undefined>();
|
||||
const [director, setDirector] = useState<string | undefined>();
|
||||
|
||||
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
|
||||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [existingTagIds, setExistingTagIds] = useState<string[]>();
|
||||
const [updateInput, setUpdateInput] = useState<GQL.BulkGroupUpdateInput>({
|
||||
ids: props.selected.map((group) => {
|
||||
return group.id;
|
||||
}),
|
||||
});
|
||||
|
||||
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
const [containingGroupsMode, setGroupMode] =
|
||||
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
|
||||
const [containingGroups, setGroups] = useState<IRelatedGroupEntry[]>();
|
||||
const [existingContainingGroups, setExistingContainingGroups] =
|
||||
useState<IRelatedGroupEntry[]>();
|
||||
|
||||
const [updateGroups] = useBulkGroupUpdate(getGroupInput());
|
||||
const unsetDisabled = props.selected.length < 2;
|
||||
|
||||
const [updateGroups] = useBulkGroupUpdate();
|
||||
|
||||
const [dateError, setDateError] = useState<string | undefined>();
|
||||
|
||||
// Network state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
function getGroupInput(): GQL.BulkGroupUpdateInput {
|
||||
const aggregateRating = getAggregateRating(props.selected);
|
||||
const aggregateStudioId = getAggregateStudioId(props.selected);
|
||||
const aggregateTagIds = getAggregateTagIds(props.selected);
|
||||
const aggregateState = useMemo(() => {
|
||||
const updateState: Partial<GQL.BulkGroupUpdateInput> = {};
|
||||
const state = props.selected;
|
||||
updateState.studio_id = getAggregateStudioId(props.selected);
|
||||
const updateTagIds = getAggregateTagIds(props.selected);
|
||||
const aggregateGroups = getAggregateContainingGroups(props.selected);
|
||||
let first = true;
|
||||
|
||||
state.forEach((group: GQL.ListGroupDataFragment) => {
|
||||
getAggregateStateObject(updateState, group, groupFields, first);
|
||||
first = false;
|
||||
});
|
||||
|
||||
return {
|
||||
state: updateState,
|
||||
tagIds: updateTagIds,
|
||||
containingGroups: aggregateGroups,
|
||||
};
|
||||
}, [props.selected]);
|
||||
|
||||
// update initial state from aggregate
|
||||
useEffect(() => {
|
||||
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
|
||||
}, [aggregateState]);
|
||||
|
||||
useEffect(() => {
|
||||
setDateError(getDateError(updateInput.date ?? "", intl));
|
||||
}, [updateInput.date, intl]);
|
||||
|
||||
function setUpdateField(input: Partial<GQL.BulkGroupUpdateInput>) {
|
||||
setUpdateInput((current) => ({ ...current, ...input }));
|
||||
}
|
||||
|
||||
function getGroupInput(): GQL.BulkGroupUpdateInput {
|
||||
const groupInput: GQL.BulkGroupUpdateInput = {
|
||||
ids: props.selected.map((group) => group.id),
|
||||
director,
|
||||
...updateInput,
|
||||
tag_ids: tagIds,
|
||||
};
|
||||
|
||||
groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
|
||||
groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
|
||||
groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
|
||||
// we don't have unset functionality for the rating star control
|
||||
// so need to determine if we are setting a rating or not
|
||||
groupInput.rating100 = getAggregateInputValue(
|
||||
updateInput.rating100,
|
||||
aggregateState.state.rating100
|
||||
);
|
||||
|
||||
groupInput.containing_groups = getAggregateContainingGroupInput(
|
||||
containingGroupsMode,
|
||||
containingGroups,
|
||||
aggregateGroups
|
||||
aggregateState.containingGroups
|
||||
);
|
||||
|
||||
return groupInput;
|
||||
|
|
@ -119,13 +155,11 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
|
|||
async function onSave() {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateGroups();
|
||||
await updateGroups({ variables: { input: getGroupInput() } });
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
{
|
||||
entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase(),
|
||||
}
|
||||
{ entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase() }
|
||||
)
|
||||
);
|
||||
props.onClose(true);
|
||||
|
|
@ -135,67 +169,24 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
|
|||
setIsUpdating(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const state = props.selected;
|
||||
let updateRating: number | undefined;
|
||||
let updateStudioId: string | undefined;
|
||||
let updateTagIds: string[] = [];
|
||||
let updateContainingGroupIds: IRelatedGroupEntry[] = [];
|
||||
let updateDirector: string | undefined;
|
||||
let first = true;
|
||||
|
||||
state.forEach((group: GQL.ListGroupDataFragment) => {
|
||||
const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort();
|
||||
const groupContainingGroupIDs = (group.containing_groups ?? []).sort(
|
||||
(a, b) => a.group.id.localeCompare(b.group.id)
|
||||
);
|
||||
|
||||
if (first) {
|
||||
first = false;
|
||||
updateRating = group.rating100 ?? undefined;
|
||||
updateStudioId = group.studio?.id ?? undefined;
|
||||
updateTagIds = groupTagIDs;
|
||||
updateContainingGroupIds = groupContainingGroupIDs;
|
||||
updateDirector = group.director ?? undefined;
|
||||
} else {
|
||||
if (group.rating100 !== updateRating) {
|
||||
updateRating = undefined;
|
||||
}
|
||||
if (group.studio?.id !== updateStudioId) {
|
||||
updateStudioId = undefined;
|
||||
}
|
||||
if (group.director !== updateDirector) {
|
||||
updateDirector = undefined;
|
||||
}
|
||||
if (!isEqual(groupTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
if (!isEqual(groupContainingGroupIDs, updateContainingGroupIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setRating(updateRating);
|
||||
setStudioId(updateStudioId);
|
||||
setExistingTagIds(updateTagIds);
|
||||
setExistingContainingGroups(updateContainingGroupIds);
|
||||
setDirector(updateDirector);
|
||||
}, [props.selected]);
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "actions.edit_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "groups" }) }
|
||||
{ id: "dialogs.edit_entity_count_title" },
|
||||
{
|
||||
count: props?.selected?.length ?? 1,
|
||||
singularEntity: intl.formatMessage({ id: "group" }),
|
||||
pluralEntity: intl.formatMessage({ id: "groups" }),
|
||||
}
|
||||
)}
|
||||
accept={{
|
||||
onClick: onSave,
|
||||
text: intl.formatMessage({ id: "actions.apply" }),
|
||||
}}
|
||||
disabled={isUpdating || !!dateError}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(false),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
|
|
@ -204,74 +195,90 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
|
|||
isRunning={isUpdating}
|
||||
>
|
||||
<Form>
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<RatingSystem
|
||||
value={rating100}
|
||||
onSetRating={(value) => setRating(value ?? undefined)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "studio" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
||||
}
|
||||
ids={studioId ? [studioId] : []}
|
||||
isDisabled={isUpdating}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="containing-groups">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="containing_groups" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="rating">
|
||||
<RatingSystem
|
||||
value={updateInput.rating100}
|
||||
onSetRating={(value) =>
|
||||
setUpdateField({ rating100: value ?? undefined })
|
||||
}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="date">
|
||||
<BulkUpdateDateInput
|
||||
value={updateInput.date}
|
||||
valueChanged={(newValue) => setUpdateField({ date: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
error={dateError}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="director">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.director}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ director: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="studio">
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setUpdateField({
|
||||
studio_id: items.length > 0 ? items[0]?.id : undefined,
|
||||
})
|
||||
}
|
||||
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
|
||||
isDisabled={isUpdating}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup
|
||||
name="containing-groups"
|
||||
messageId="containing_groups"
|
||||
inline={false}
|
||||
>
|
||||
<ContainingGroupsMultiSet
|
||||
disabled={isUpdating}
|
||||
onUpdate={(v) => setGroups(v)}
|
||||
onSetMode={(newMode) => setGroupMode(newMode)}
|
||||
existingValue={existingContainingGroups ?? []}
|
||||
existingValue={aggregateState.containingGroups ?? []}
|
||||
value={containingGroups ?? []}
|
||||
mode={containingGroupsMode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="director">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="director" />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
className="input-control"
|
||||
type="text"
|
||||
value={director}
|
||||
onChange={(event) => setDirector(event.currentTarget.value)}
|
||||
placeholder={intl.formatMessage({ id: "director" })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="tags" />
|
||||
</Form.Label>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="tags" inline={false}>
|
||||
<MultiSet
|
||||
type="tags"
|
||||
type={"tags"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => setTagIds(itemIDs)}
|
||||
onSetMode={(newMode) => setTagMode(newMode)}
|
||||
existingIds={existingTagIds ?? []}
|
||||
ids={tagIds ?? []}
|
||||
mode={tagMode}
|
||||
onUpdate={(itemIDs) => {
|
||||
setTagIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setTagIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={tagIds.ids ?? []}
|
||||
existingIds={aggregateState.tagIds}
|
||||
mode={tagIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="synopsis" inline={false}>
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.synopsis}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ synopsis: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
as="textarea"
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
</Form>
|
||||
</ModalComponent>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,96 +1,121 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Col, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useBulkImageUpdate } from "src/core/StashService";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StudioSelect } from "src/components/Shared/Select";
|
||||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import { StudioSelect } from "../Shared/Select";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { MultiSet } from "../Shared/MultiSet";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { RatingSystem } from "../Shared/Rating/RatingSystem";
|
||||
import {
|
||||
getAggregateGalleryIds,
|
||||
getAggregateInputIDs,
|
||||
getAggregateInputValue,
|
||||
getAggregatePerformerIds,
|
||||
getAggregateRating,
|
||||
getAggregateStudioId,
|
||||
getAggregateStateObject,
|
||||
getAggregateTagIds,
|
||||
getAggregateStudioId,
|
||||
getAggregateGalleryIds,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
|
||||
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
|
||||
import { BulkUpdateDateInput } from "../Shared/DateInput";
|
||||
import { getDateError } from "src/utils/yup";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimImageDataFragment[];
|
||||
onClose: (applied: boolean) => void;
|
||||
}
|
||||
|
||||
const imageFields = [
|
||||
"code",
|
||||
"rating100",
|
||||
"details",
|
||||
"organized",
|
||||
"photographer",
|
||||
"date",
|
||||
];
|
||||
|
||||
export const EditImagesDialog: React.FC<IListOperationProps> = (
|
||||
props: IListOperationProps
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const [rating100, setRating] = useState<number>();
|
||||
const [studioId, setStudioId] = useState<string>();
|
||||
const [performerMode, setPerformerMode] =
|
||||
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
|
||||
const [performerIds, setPerformerIds] = useState<string[]>();
|
||||
const [existingPerformerIds, setExistingPerformerIds] = useState<string[]>();
|
||||
|
||||
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
|
||||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [existingTagIds, setExistingTagIds] = useState<string[]>();
|
||||
const [updateInput, setUpdateInput] = useState<GQL.BulkImageUpdateInput>({
|
||||
ids: props.selected.map((image) => {
|
||||
return image.id;
|
||||
}),
|
||||
});
|
||||
|
||||
const [galleryMode, setGalleryMode] = React.useState<GQL.BulkUpdateIdMode>(
|
||||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [galleryIds, setGalleryIds] = useState<string[]>();
|
||||
const [existingGalleryIds, setExistingGalleryIds] = useState<string[]>();
|
||||
const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
const [galleryIds, setGalleryIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
|
||||
const [organized, setOrganized] = useState<boolean | undefined>();
|
||||
const unsetDisabled = props.selected.length < 2;
|
||||
|
||||
const [dateError, setDateError] = useState<string | undefined>();
|
||||
|
||||
const [updateImages] = useBulkImageUpdate();
|
||||
|
||||
// Network state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||
const aggregateState = useMemo(() => {
|
||||
const updateState: Partial<GQL.BulkImageUpdateInput> = {};
|
||||
const state = props.selected;
|
||||
updateState.studio_id = getAggregateStudioId(props.selected);
|
||||
const updateTagIds = getAggregateTagIds(props.selected);
|
||||
const updatePerformerIds = getAggregatePerformerIds(props.selected);
|
||||
const updateGalleryIds = getAggregateGalleryIds(props.selected);
|
||||
let first = true;
|
||||
|
||||
state.forEach((image: GQL.SlimImageDataFragment) => {
|
||||
getAggregateStateObject(updateState, image, imageFields, first);
|
||||
first = false;
|
||||
});
|
||||
|
||||
return {
|
||||
state: updateState,
|
||||
tagIds: updateTagIds,
|
||||
performerIds: updatePerformerIds,
|
||||
galleryIds: updateGalleryIds,
|
||||
};
|
||||
}, [props.selected]);
|
||||
|
||||
// update initial state from aggregate
|
||||
useEffect(() => {
|
||||
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
|
||||
}, [aggregateState]);
|
||||
|
||||
useEffect(() => {
|
||||
setDateError(getDateError(updateInput.date ?? "", intl));
|
||||
}, [updateInput.date, intl]);
|
||||
|
||||
function setUpdateField(input: Partial<GQL.BulkImageUpdateInput>) {
|
||||
setUpdateInput((current) => ({ ...current, ...input }));
|
||||
}
|
||||
|
||||
function getImageInput(): GQL.BulkImageUpdateInput {
|
||||
// need to determine what we are actually setting on each image
|
||||
const aggregateRating = getAggregateRating(props.selected);
|
||||
const aggregateStudioId = getAggregateStudioId(props.selected);
|
||||
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
|
||||
const aggregateTagIds = getAggregateTagIds(props.selected);
|
||||
const aggregateGalleryIds = getAggregateGalleryIds(props.selected);
|
||||
|
||||
const imageInput: GQL.BulkImageUpdateInput = {
|
||||
ids: props.selected.map((image) => {
|
||||
return image.id;
|
||||
}),
|
||||
...updateInput,
|
||||
tag_ids: tagIds,
|
||||
performer_ids: performerIds,
|
||||
gallery_ids: galleryIds,
|
||||
};
|
||||
|
||||
imageInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
|
||||
imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
|
||||
|
||||
imageInput.performer_ids = getAggregateInputIDs(
|
||||
performerMode,
|
||||
performerIds,
|
||||
aggregatePerformerIds
|
||||
// we don't have unset functionality for the rating star control
|
||||
// so need to determine if we are setting a rating or not
|
||||
imageInput.rating100 = getAggregateInputValue(
|
||||
updateInput.rating100,
|
||||
aggregateState.state.rating100
|
||||
);
|
||||
imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
|
||||
imageInput.gallery_ids = getAggregateInputIDs(
|
||||
galleryMode,
|
||||
galleryIds,
|
||||
aggregateGalleryIds
|
||||
);
|
||||
|
||||
if (organized !== undefined) {
|
||||
imageInput.organized = organized;
|
||||
}
|
||||
|
||||
return imageInput;
|
||||
}
|
||||
|
|
@ -98,11 +123,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
async function onSave() {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateImages({
|
||||
variables: {
|
||||
input: getImageInput(),
|
||||
},
|
||||
});
|
||||
await updateImages({ variables: { input: getImageInput() } });
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
|
|
@ -116,86 +137,13 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
setIsUpdating(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const state = props.selected;
|
||||
let updateRating: number | undefined;
|
||||
let updateStudioID: string | undefined;
|
||||
let updatePerformerIds: string[] = [];
|
||||
let updateTagIds: string[] = [];
|
||||
let updateGalleryIds: string[] = [];
|
||||
let updateOrganized: boolean | undefined;
|
||||
let first = true;
|
||||
|
||||
state.forEach((image: GQL.SlimImageDataFragment) => {
|
||||
const imageRating = image.rating100;
|
||||
const imageStudioID = image?.studio?.id;
|
||||
const imagePerformerIDs = (image.performers ?? [])
|
||||
.map((p) => p.id)
|
||||
.sort();
|
||||
const imageTagIDs = (image.tags ?? []).map((p) => p.id).sort();
|
||||
const imageGalleryIDs = (image.galleries ?? []).map((p) => p.id).sort();
|
||||
|
||||
if (first) {
|
||||
updateRating = imageRating ?? undefined;
|
||||
updateStudioID = imageStudioID;
|
||||
updatePerformerIds = imagePerformerIDs;
|
||||
updateTagIds = imageTagIDs;
|
||||
updateGalleryIds = imageGalleryIDs;
|
||||
updateOrganized = image.organized;
|
||||
first = false;
|
||||
} else {
|
||||
if (imageRating !== updateRating) {
|
||||
updateRating = undefined;
|
||||
}
|
||||
if (imageStudioID !== updateStudioID) {
|
||||
updateStudioID = undefined;
|
||||
}
|
||||
if (!isEqual(imagePerformerIDs, updatePerformerIds)) {
|
||||
updatePerformerIds = [];
|
||||
}
|
||||
if (!isEqual(imageTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
if (!isEqual(imageGalleryIDs, updateGalleryIds)) {
|
||||
updateGalleryIds = [];
|
||||
}
|
||||
if (image.organized !== updateOrganized) {
|
||||
updateOrganized = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setRating(updateRating);
|
||||
setStudioId(updateStudioID);
|
||||
setExistingPerformerIds(updatePerformerIds);
|
||||
setExistingTagIds(updateTagIds);
|
||||
setExistingGalleryIds(updateGalleryIds);
|
||||
setOrganized(updateOrganized);
|
||||
}, [props.selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = organized === undefined;
|
||||
}
|
||||
}, [organized, checkboxRef]);
|
||||
|
||||
function cycleOrganized() {
|
||||
if (organized) {
|
||||
setOrganized(undefined);
|
||||
} else if (organized === undefined) {
|
||||
setOrganized(false);
|
||||
} else {
|
||||
setOrganized(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "dialogs.edit_entity_title" },
|
||||
{ id: "dialogs.edit_entity_count_title" },
|
||||
{
|
||||
count: props?.selected?.length ?? 1,
|
||||
singularEntity: intl.formatMessage({ id: "image" }),
|
||||
|
|
@ -206,6 +154,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
onClick: onSave,
|
||||
text: intl.formatMessage({ id: "actions.apply" }),
|
||||
}}
|
||||
disabled={isUpdating || !!dateError}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(false),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
|
|
@ -214,89 +163,120 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
isRunning={isUpdating}
|
||||
>
|
||||
<Form>
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<RatingSystem
|
||||
value={rating100}
|
||||
onSetRating={(value) => setRating(value ?? undefined)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "studio" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
||||
}
|
||||
ids={studioId ? [studioId] : []}
|
||||
isDisabled={isUpdating}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="performers">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="performers" />
|
||||
</Form.Label>
|
||||
<MultiSet
|
||||
type="performers"
|
||||
<BulkUpdateFormGroup name="rating">
|
||||
<RatingSystem
|
||||
value={updateInput.rating100}
|
||||
onSetRating={(value) =>
|
||||
setUpdateField({ rating100: value ?? undefined })
|
||||
}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => setPerformerIds(itemIDs)}
|
||||
onSetMode={(newMode) => setPerformerMode(newMode)}
|
||||
existingIds={existingPerformerIds ?? []}
|
||||
ids={performerIds ?? []}
|
||||
mode={performerMode}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="scene_code">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.code}
|
||||
valueChanged={(newValue) => setUpdateField({ code: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="date">
|
||||
<BulkUpdateDateInput
|
||||
value={updateInput.date}
|
||||
valueChanged={(newValue) => setUpdateField({ date: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
error={dateError}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="photographer">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.photographer}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ photographer: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="studio">
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setUpdateField({
|
||||
studio_id: items.length > 0 ? items[0]?.id : undefined,
|
||||
})
|
||||
}
|
||||
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
|
||||
isDisabled={isUpdating}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="tags" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="performers" inline={false}>
|
||||
<MultiSet
|
||||
type="tags"
|
||||
type={"performers"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => setTagIds(itemIDs)}
|
||||
onSetMode={(newMode) => setTagMode(newMode)}
|
||||
existingIds={existingTagIds ?? []}
|
||||
ids={tagIds ?? []}
|
||||
mode={tagMode}
|
||||
onUpdate={(itemIDs) => {
|
||||
setPerformerIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setPerformerIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={performerIds.ids ?? []}
|
||||
existingIds={aggregateState.performerIds}
|
||||
mode={performerIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="galleries">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="galleries" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="galleries" inline={false}>
|
||||
<MultiSet
|
||||
type="galleries"
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => setGalleryIds(itemIDs)}
|
||||
onSetMode={(newMode) => setGalleryMode(newMode)}
|
||||
existingIds={existingGalleryIds ?? []}
|
||||
ids={galleryIds ?? []}
|
||||
mode={galleryMode}
|
||||
onUpdate={(itemIDs) => {
|
||||
setGalleryIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setGalleryIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={galleryIds.ids ?? []}
|
||||
existingIds={aggregateState.galleryIds}
|
||||
mode={galleryIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="tags" inline={false}>
|
||||
<MultiSet
|
||||
type={"tags"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => {
|
||||
setTagIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setTagIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={tagIds.ids ?? []}
|
||||
existingIds={aggregateState.tagIds}
|
||||
mode={tagIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="details" inline={false}>
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.details}
|
||||
valueChanged={(newValue) => setUpdateField({ details: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
as="textarea"
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="organized">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
<IndeterminateCheckbox
|
||||
label={intl.formatMessage({ id: "organized" })}
|
||||
checked={organized}
|
||||
ref={checkboxRef}
|
||||
onChange={() => cycleOrganized()}
|
||||
setChecked={(checked) => setUpdateField({ organized: checked })}
|
||||
checked={updateInput.organized ?? undefined}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -751,7 +751,7 @@ export const FilteredImageList = PatchComponent(
|
|||
currentPage={filter.currentPage}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
totalItems={totalCount}
|
||||
onChangePage={(page) => setFilter(filter.changePage(page))}
|
||||
onChangePage={setPage}
|
||||
/>
|
||||
<PaginationIndex
|
||||
loading={cachedResult.loading}
|
||||
|
|
@ -766,7 +766,7 @@ export const FilteredImageList = PatchComponent(
|
|||
<ImageList
|
||||
filter={filter}
|
||||
images={items}
|
||||
onChangePage={(page) => setFilter(filter.changePage(page))}
|
||||
onChangePage={setPage}
|
||||
onSelectChange={onSelectChange}
|
||||
pageCount={pageCount}
|
||||
selectedIds={selectedIds}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
|
|
@ -489,20 +489,20 @@ export function useCachedQueryResult<T extends QueryResult>(
|
|||
result: T
|
||||
) {
|
||||
const [cachedResult, setCachedResult] = useState(result);
|
||||
const [lastFilter, setLastFilter] = useState(filter);
|
||||
const lastFilterRef = useRef(filter);
|
||||
|
||||
// if we are only changing the page or sort, don't update the result count
|
||||
useEffect(() => {
|
||||
if (!result.loading) {
|
||||
setCachedResult(result);
|
||||
} else {
|
||||
if (totalCountImpacted(lastFilter, filter)) {
|
||||
if (totalCountImpacted(lastFilterRef.current, filter)) {
|
||||
setCachedResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
setLastFilter(filter);
|
||||
}, [filter, result, lastFilter]);
|
||||
lastFilterRef.current = filter;
|
||||
}, [filter, result]);
|
||||
|
||||
return cachedResult;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Col, Form, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useBulkPerformerUpdate } from "src/core/StashService";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
|
|
@ -23,12 +23,13 @@ import {
|
|||
stringToCircumcised,
|
||||
} from "src/utils/circumcised";
|
||||
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
|
||||
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
|
||||
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import { CountrySelect } from "../Shared/CountrySelect";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import cx from "classnames";
|
||||
import { BulkUpdateDateInput } from "../Shared/DateInput";
|
||||
import { getDateError } from "src/utils/yup";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimPerformerDataFragment[];
|
||||
|
|
@ -75,17 +76,42 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
const [aggregateState, setAggregateState] =
|
||||
useState<GQL.BulkPerformerUpdateInput>({});
|
||||
// height and weight needs conversion to/from number
|
||||
const [height, setHeight] = useState<string | undefined>();
|
||||
const [weight, setWeight] = useState<string | undefined>();
|
||||
const [penis_length, setPenisLength] = useState<string | undefined>();
|
||||
const [height, setHeight] = useState<string | undefined | null>();
|
||||
const [weight, setWeight] = useState<string | undefined | null>();
|
||||
const [penis_length, setPenisLength] = useState<string | undefined | null>();
|
||||
const [updateInput, setUpdateInput] = useState<GQL.BulkPerformerUpdateInput>(
|
||||
{}
|
||||
);
|
||||
const genderOptions = [""].concat(genderStrings);
|
||||
const circumcisedOptions = [""].concat(circumcisedStrings);
|
||||
|
||||
const unsetDisabled = props.selected.length < 2;
|
||||
|
||||
const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput());
|
||||
|
||||
const [birthdateError, setBirthdateError] = useState<string | undefined>();
|
||||
const [deathDateError, setDeathDateError] = useState<string | undefined>();
|
||||
const [careerStartError, setCareerStartError] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [careerEndError, setCareerEndError] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
setBirthdateError(getDateError(updateInput.birthdate ?? "", intl));
|
||||
}, [updateInput.birthdate, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
setDeathDateError(getDateError(updateInput.death_date ?? "", intl));
|
||||
}, [updateInput.death_date, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
setCareerStartError(getDateError(updateInput.career_start ?? "", intl));
|
||||
}, [updateInput.career_start, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
setCareerEndError(getDateError(updateInput.career_end ?? "", intl));
|
||||
}, [updateInput.career_end, intl]);
|
||||
|
||||
// Network state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
|
|
@ -121,14 +147,14 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
);
|
||||
|
||||
if (height !== undefined) {
|
||||
performerInput.height_cm = parseFloat(height);
|
||||
performerInput.height_cm = height === null ? null : parseFloat(height);
|
||||
}
|
||||
if (weight !== undefined) {
|
||||
performerInput.weight = parseFloat(weight);
|
||||
performerInput.weight = weight === null ? null : parseFloat(weight);
|
||||
}
|
||||
|
||||
if (penis_length !== undefined) {
|
||||
performerInput.penis_length = parseFloat(penis_length);
|
||||
performerInput.penis_length =
|
||||
penis_length === null ? null : parseFloat(penis_length);
|
||||
}
|
||||
|
||||
return performerInput;
|
||||
|
|
@ -205,25 +231,6 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
setUpdateInput(updateState);
|
||||
}, [props.selected]);
|
||||
|
||||
function renderTextField(
|
||||
name: string,
|
||||
value: string | undefined | null,
|
||||
setter: (newValue: string | undefined) => void
|
||||
) {
|
||||
return (
|
||||
<Form.Group controlId={name} data-field={name}>
|
||||
<Form.Label>
|
||||
<FormattedMessage id={name} />
|
||||
</Form.Label>
|
||||
<BulkUpdateTextInput
|
||||
value={value === null ? "" : value ?? undefined}
|
||||
valueChanged={(newValue) => setter(newValue)}
|
||||
unsetDisabled={props.selected.length < 2}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
// sfw class needs to be set because it is outside body
|
||||
|
||||
|
|
@ -235,13 +242,18 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
show
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "actions.edit_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "performers" }) }
|
||||
{ id: "dialogs.edit_entity_count_title" },
|
||||
{
|
||||
count: props?.selected?.length ?? 1,
|
||||
singularEntity: intl.formatMessage({ id: "performer" }),
|
||||
pluralEntity: intl.formatMessage({ id: "performers" }),
|
||||
}
|
||||
)}
|
||||
accept={{
|
||||
onClick: onSave,
|
||||
text: intl.formatMessage({ id: "actions.apply" }),
|
||||
}}
|
||||
disabled={isUpdating || !!birthdateError || !!deathDateError}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(false),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
|
|
@ -249,11 +261,8 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
}}
|
||||
isRunning={isUpdating}
|
||||
>
|
||||
<Form.Group controlId="rating" as={Row} data-field={name}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form>
|
||||
<BulkUpdateFormGroup name="rating">
|
||||
<RatingSystem
|
||||
value={updateInput.rating100}
|
||||
onSetRating={(value) =>
|
||||
|
|
@ -261,9 +270,8 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="favorite">
|
||||
<IndeterminateCheckbox
|
||||
setChecked={(checked) => setUpdateField({ favorite: checked })}
|
||||
|
|
@ -272,10 +280,7 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<FormattedMessage id="gender" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="gender">
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="input-control"
|
||||
|
|
@ -292,51 +297,105 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
{renderTextField("disambiguation", updateInput.disambiguation, (v) =>
|
||||
setUpdateField({ disambiguation: v })
|
||||
)}
|
||||
{renderTextField("birthdate", updateInput.birthdate, (v) =>
|
||||
setUpdateField({ birthdate: v })
|
||||
)}
|
||||
{renderTextField("death_date", updateInput.death_date, (v) =>
|
||||
setUpdateField({ death_date: v })
|
||||
)}
|
||||
<BulkUpdateFormGroup name="disambiguation">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.disambiguation}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ disambiguation: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<FormattedMessage id="country" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="birthdate">
|
||||
<BulkUpdateDateInput
|
||||
value={updateInput.birthdate}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ birthdate: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
error={birthdateError}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="death_date">
|
||||
<BulkUpdateDateInput
|
||||
value={updateInput.death_date}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ death_date: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
error={deathDateError}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="country">
|
||||
<CountrySelect
|
||||
value={updateInput.country ?? ""}
|
||||
onChange={(v) => setUpdateField({ country: v })}
|
||||
showFlag
|
||||
/>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
{renderTextField("ethnicity", updateInput.ethnicity, (v) =>
|
||||
setUpdateField({ ethnicity: v })
|
||||
)}
|
||||
{renderTextField("hair_color", updateInput.hair_color, (v) =>
|
||||
setUpdateField({ hair_color: v })
|
||||
)}
|
||||
{renderTextField("eye_color", updateInput.eye_color, (v) =>
|
||||
setUpdateField({ eye_color: v })
|
||||
)}
|
||||
{renderTextField("height", height, (v) => setHeight(v))}
|
||||
{renderTextField("weight", weight, (v) => setWeight(v))}
|
||||
{renderTextField("measurements", updateInput.measurements, (v) =>
|
||||
setUpdateField({ measurements: v })
|
||||
)}
|
||||
{renderTextField("penis_length", penis_length, (v) =>
|
||||
setPenisLength(v)
|
||||
)}
|
||||
<BulkUpdateFormGroup name="ethnicity">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.ethnicity}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ ethnicity: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="hair_color">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.hair_color}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ hair_color: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="eye_color">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.eye_color}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ eye_color: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="height">
|
||||
<BulkUpdateTextInput
|
||||
value={height}
|
||||
valueChanged={(newValue) => setHeight(newValue)}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="weight">
|
||||
<BulkUpdateTextInput
|
||||
value={weight}
|
||||
valueChanged={(newValue) => setWeight(newValue)}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="measurements">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.measurements}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ measurements: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="penis_length">
|
||||
<BulkUpdateTextInput
|
||||
value={penis_length}
|
||||
valueChanged={(newValue) => setPenisLength(newValue)}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group data-field="circumcised">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="circumcised" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="circumcised">
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="input-control"
|
||||
|
|
@ -353,43 +412,70 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
{renderTextField("fake_tits", updateInput.fake_tits, (v) =>
|
||||
setUpdateField({ fake_tits: v })
|
||||
)}
|
||||
{renderTextField("tattoos", updateInput.tattoos, (v) =>
|
||||
setUpdateField({ tattoos: v })
|
||||
)}
|
||||
{renderTextField("piercings", updateInput.piercings, (v) =>
|
||||
setUpdateField({ piercings: v })
|
||||
)}
|
||||
{renderTextField(
|
||||
"career_start",
|
||||
updateInput.career_start?.toString(),
|
||||
(v) => setUpdateField({ career_start: v ? parseInt(v) : undefined })
|
||||
)}
|
||||
{renderTextField(
|
||||
"career_end",
|
||||
updateInput.career_end?.toString(),
|
||||
(v) => setUpdateField({ career_end: v ? parseInt(v) : undefined })
|
||||
)}
|
||||
<BulkUpdateFormGroup name="fake_tits">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.fake_tits}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ fake_tits: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="tattoos">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.tattoos}
|
||||
valueChanged={(newValue) => setUpdateField({ tattoos: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="piercings">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.piercings}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ piercings: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="career_start">
|
||||
<BulkUpdateDateInput
|
||||
value={updateInput.career_start}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ career_start: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
error={careerStartError}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="career_end">
|
||||
<BulkUpdateDateInput
|
||||
value={updateInput.career_end}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ career_end: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
error={careerEndError}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="tags" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="tags" inline={false}>
|
||||
<MultiSet
|
||||
type="tags"
|
||||
type={"tags"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => setTagIds({ ...tagIds, ids: itemIDs })}
|
||||
onSetMode={(newMode) => setTagIds({ ...tagIds, mode: newMode })}
|
||||
existingIds={existingTagIds ?? []}
|
||||
onUpdate={(itemIDs) => {
|
||||
setTagIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setTagIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={tagIds.ids ?? []}
|
||||
existingIds={existingTagIds}
|
||||
mode={tagIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="ignore-auto-tags">
|
||||
<IndeterminateCheckbox
|
||||
|
|
|
|||
|
|
@ -116,11 +116,11 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
measurements: yup.string().ensure(),
|
||||
fake_tits: yup.string().ensure(),
|
||||
penis_length: yupInputNumber().positive().nullable().defined(),
|
||||
circumcised: yupInputEnum(GQL.CircumisedEnum).nullable().defined(),
|
||||
circumcised: yupInputEnum(GQL.CircumcisedEnum).nullable().defined(),
|
||||
tattoos: yup.string().ensure(),
|
||||
piercings: yup.string().ensure(),
|
||||
career_start: yupInputNumber().positive().nullable().defined(),
|
||||
career_end: yupInputNumber().positive().nullable().defined(),
|
||||
career_start: yupDateString(intl),
|
||||
career_end: yupDateString(intl),
|
||||
urls: yupUniqueStringList(intl),
|
||||
details: yup.string().ensure(),
|
||||
tag_ids: yup.array(yup.string().required()).defined(),
|
||||
|
|
@ -149,8 +149,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
circumcised: performer.circumcised ?? null,
|
||||
tattoos: performer.tattoos ?? "",
|
||||
piercings: performer.piercings ?? "",
|
||||
career_start: performer.career_start ?? null,
|
||||
career_end: performer.career_end ?? null,
|
||||
career_start: performer.career_start ?? "",
|
||||
career_end: performer.career_end ?? "",
|
||||
urls: performer.urls ?? [],
|
||||
details: performer.details ?? "",
|
||||
tag_ids: (performer.tags ?? []).map((t) => t.id),
|
||||
|
|
@ -745,8 +745,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
{renderInputField("tattoos", "textarea")}
|
||||
{renderInputField("piercings", "textarea")}
|
||||
|
||||
{renderInputField("career_start", "number")}
|
||||
{renderInputField("career_end", "number")}
|
||||
{renderDateField("career_start")}
|
||||
{renderDateField("career_end")}
|
||||
|
||||
{renderURLListField("urls", onScrapePerformerURL, urlScrapable)}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
ScrapedTextAreaRow,
|
||||
ScrapedCountryRow,
|
||||
ScrapedStringListRow,
|
||||
ScrapedNumberRow,
|
||||
} from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
|
||||
import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
|
||||
import { Form } from "react-bootstrap";
|
||||
|
|
@ -191,7 +190,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
|||
return;
|
||||
}
|
||||
|
||||
let retEnum: GQL.CircumisedEnum | undefined;
|
||||
let retEnum: GQL.CircumcisedEnum | undefined;
|
||||
|
||||
// try to translate from enum values first
|
||||
const upperCircumcised = scrapedCircumcised.toUpperCase();
|
||||
|
|
@ -273,14 +272,14 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
|||
const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.performer.fake_tits, props.scraped.fake_tits)
|
||||
);
|
||||
const [careerStart, setCareerStart] = useState<ScrapeResult<number>>(
|
||||
new ScrapeResult<number>(
|
||||
const [careerStart, setCareerStart] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(
|
||||
props.performer.career_start,
|
||||
props.scraped.career_start
|
||||
)
|
||||
);
|
||||
const [careerEnd, setCareerEnd] = useState<ScrapeResult<number>>(
|
||||
new ScrapeResult<number>(
|
||||
const [careerEnd, setCareerEnd] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(
|
||||
props.performer.career_end,
|
||||
props.scraped.career_end
|
||||
)
|
||||
|
|
@ -502,13 +501,13 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
|||
result={fakeTits}
|
||||
onChange={(value) => setFakeTits(value)}
|
||||
/>
|
||||
<ScrapedNumberRow
|
||||
<ScrapedInputGroupRow
|
||||
field="career_start"
|
||||
title={intl.formatMessage({ id: "career_start" })}
|
||||
result={careerStart}
|
||||
onChange={(value) => setCareerStart(value)}
|
||||
/>
|
||||
<ScrapedNumberRow
|
||||
<ScrapedInputGroupRow
|
||||
field="career_end"
|
||||
title={intl.formatMessage({ id: "career_end" })}
|
||||
result={careerEnd}
|
||||
|
|
|
|||
|
|
@ -138,14 +138,15 @@ export const FormatWeight = (weight?: number | null) => {
|
|||
};
|
||||
|
||||
export function formatYearRange(
|
||||
start?: number | null,
|
||||
end?: number | null
|
||||
start?: string | null,
|
||||
end?: string | null
|
||||
): string | undefined {
|
||||
if (!start && !end) return undefined;
|
||||
|
||||
return `${start ?? ""} - ${end ?? ""}`;
|
||||
}
|
||||
|
||||
export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => {
|
||||
export const FormatCircumcised = (circumcised?: GQL.CircumcisedEnum | null) => {
|
||||
const intl = useIntl();
|
||||
if (!circumcised) {
|
||||
return "";
|
||||
|
|
|
|||
|
|
@ -20,13 +20,14 @@ import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
|
|||
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
|
||||
import {
|
||||
ScrapedCustomFieldRows,
|
||||
ScrapeDialogRow,
|
||||
ScrapedImageRow,
|
||||
ScrapedInputGroupRow,
|
||||
ScrapedStringListRow,
|
||||
ScrapedTextAreaRow,
|
||||
} from "../Shared/ScrapeDialog/ScrapeDialogRow";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { sortStoredIdObjects } from "src/utils/data";
|
||||
import { sortStoredIdObjects, uniqIDStoredIDs } from "src/utils/data";
|
||||
import {
|
||||
CustomFieldScrapeResults,
|
||||
ObjectListScrapeResult,
|
||||
|
|
@ -40,6 +41,7 @@ import {
|
|||
} from "./PerformerDetails/PerformerScrapeDialog";
|
||||
import { PerformerSelect } from "./PerformerSelect";
|
||||
import { uniq } from "lodash-es";
|
||||
import { StashIDsField } from "../Shared/StashID";
|
||||
|
||||
type MergeOptions = {
|
||||
values: GQL.PerformerUpdateInput;
|
||||
|
|
@ -132,6 +134,8 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
|||
)
|
||||
);
|
||||
|
||||
const [stashIDs, setStashIDs] = useState(new ScrapeResult<GQL.StashId[]>([]));
|
||||
|
||||
const [image, setImage] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.image_path)
|
||||
);
|
||||
|
|
@ -166,6 +170,10 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
|||
setLoading(false);
|
||||
}
|
||||
|
||||
// append dest to all so that if dest has stash_ids with the same
|
||||
// endpoint, then it will be excluded first
|
||||
const all = sources.concat(dest);
|
||||
|
||||
setName(
|
||||
new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)
|
||||
);
|
||||
|
|
@ -297,9 +305,8 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
|||
);
|
||||
setURLs(
|
||||
new ScrapeResult(
|
||||
dest.urls,
|
||||
sources.find((s) => s.urls)?.urls,
|
||||
!dest.urls?.length
|
||||
dest.urls ?? [],
|
||||
uniq(all.map((s) => s.urls ?? []).flat())
|
||||
)
|
||||
);
|
||||
setGender(
|
||||
|
|
@ -327,6 +334,25 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
|||
!dest.details
|
||||
)
|
||||
);
|
||||
setTags(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(dest.tags.map(idToStoredID)),
|
||||
uniqIDStoredIDs(all.map((s) => s.tags.map(idToStoredID)).flat())
|
||||
)
|
||||
);
|
||||
setStashIDs(
|
||||
new ScrapeResult(
|
||||
dest.stash_ids,
|
||||
all
|
||||
.map((s) => s.stash_ids)
|
||||
.flat()
|
||||
.filter((s, index, a) => {
|
||||
// remove entries with duplicate endpoints
|
||||
return index === a.findIndex((ss) => ss.endpoint === s.endpoint);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
setImage(
|
||||
new ScrapeResult(
|
||||
dest.image_path,
|
||||
|
|
@ -583,6 +609,19 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
|||
result={details}
|
||||
onChange={(value) => setDetails(value)}
|
||||
/>
|
||||
<ScrapeDialogRow
|
||||
field="stash_ids"
|
||||
title={intl.formatMessage({ id: "stash_id" })}
|
||||
result={stashIDs}
|
||||
originalField={
|
||||
<StashIDsField values={stashIDs?.originalValue ?? []} />
|
||||
}
|
||||
newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
|
||||
onChange={(value) => setStashIDs(value)}
|
||||
alwaysShow={
|
||||
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length
|
||||
}
|
||||
/>
|
||||
<ScrapedImageRow
|
||||
field="image"
|
||||
title={intl.formatMessage({ id: "performer_image" })}
|
||||
|
|
@ -630,12 +669,8 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
|||
: undefined,
|
||||
measurements: measurements.getNewValue(),
|
||||
fake_tits: fakeTits.getNewValue(),
|
||||
career_start: careerStart.getNewValue()
|
||||
? parseInt(careerStart.getNewValue()!)
|
||||
: undefined,
|
||||
career_end: careerEnd.getNewValue()
|
||||
? parseInt(careerEnd.getNewValue()!)
|
||||
: undefined,
|
||||
career_start: careerStart.getNewValue(),
|
||||
career_end: careerEnd.getNewValue(),
|
||||
tattoos: tattoos.getNewValue(),
|
||||
piercings: piercings.getNewValue(),
|
||||
urls: urls.getNewValue(),
|
||||
|
|
@ -643,6 +678,7 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
|||
circumcised: stringToCircumcised(circumcised.getNewValue()),
|
||||
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
|
||||
details: details.getNewValue(),
|
||||
stash_ids: stashIDs.getNewValue(),
|
||||
image: coverImage,
|
||||
custom_fields: {
|
||||
partial: Object.fromEntries(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useBulkSceneMarkerUpdate } from "src/core/StashService";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
getAggregateState,
|
||||
getAggregateStateObject,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
|
||||
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { TagSelect } from "../Shared/Select";
|
||||
|
||||
|
|
@ -38,6 +38,8 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
|
|||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
|
||||
const unsetDisabled = props.selected.length < 2;
|
||||
|
||||
const [updateSceneMarkers] = useBulkSceneMarkerUpdate();
|
||||
|
||||
// Network state
|
||||
|
|
@ -115,27 +117,6 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
|
|||
setIsUpdating(false);
|
||||
}
|
||||
|
||||
function renderTextField(
|
||||
name: string,
|
||||
value: string | undefined | null,
|
||||
setter: (newValue: string | undefined) => void,
|
||||
area: boolean = false
|
||||
) {
|
||||
return (
|
||||
<Form.Group controlId={name}>
|
||||
<Form.Label>
|
||||
<FormattedMessage id={name} />
|
||||
</Form.Label>
|
||||
<BulkUpdateTextInput
|
||||
value={value === null ? "" : value ?? undefined}
|
||||
valueChanged={(newValue) => setter(newValue)}
|
||||
unsetDisabled={props.selected.length < 2}
|
||||
as={area ? "textarea" : undefined}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<ModalComponent
|
||||
|
|
@ -143,8 +124,12 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
|
|||
show
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "actions.edit_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "markers" }) }
|
||||
{ id: "dialogs.edit_entity_count_title" },
|
||||
{
|
||||
count: props?.selected?.length ?? 1,
|
||||
singularEntity: intl.formatMessage({ id: "marker" }),
|
||||
pluralEntity: intl.formatMessage({ id: "markers" }),
|
||||
}
|
||||
)}
|
||||
accept={{
|
||||
onClick: onSave,
|
||||
|
|
@ -158,39 +143,39 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
|
|||
isRunning={isUpdating}
|
||||
>
|
||||
<Form>
|
||||
{renderTextField("title", updateInput.title, (newValue) =>
|
||||
setUpdateField({ title: newValue })
|
||||
)}
|
||||
<BulkUpdateFormGroup name="title">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.title}
|
||||
valueChanged={(newValue) => setUpdateField({ title: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="primary-tag">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="primary_tag" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="primary-tag" messageId="primary_tag">
|
||||
<TagSelect
|
||||
onSelect={(t) => setUpdateField({ primary_tag_id: t[0]?.id })}
|
||||
ids={
|
||||
updateInput.primary_tag_id ? [updateInput.primary_tag_id] : []
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="tags" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="tags" inline={false}>
|
||||
<MultiSet
|
||||
type="tags"
|
||||
type={"tags"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => setTagIds((v) => ({ ...v, ids: itemIDs }))}
|
||||
onSetMode={(newMode) =>
|
||||
setTagIds((v) => ({ ...v, mode: newMode }))
|
||||
}
|
||||
existingIds={aggregateState.tagIds ?? []}
|
||||
onUpdate={(itemIDs) => {
|
||||
setTagIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setTagIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={tagIds.ids ?? []}
|
||||
existingIds={aggregateState.tagIds ?? []}
|
||||
mode={tagIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
</Form>
|
||||
</ModalComponent>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,93 +1,121 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Col, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useBulkSceneUpdate } from "src/core/StashService";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StudioSelect } from "../Shared/Select";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { MultiSet } from "../Shared/MultiSet";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import { RatingSystem } from "../Shared/Rating/RatingSystem";
|
||||
import {
|
||||
getAggregateInputIDs,
|
||||
getAggregateInputValue,
|
||||
getAggregateGroupIds,
|
||||
getAggregatePerformerIds,
|
||||
getAggregateRating,
|
||||
getAggregateStudioId,
|
||||
getAggregateStateObject,
|
||||
getAggregateTagIds,
|
||||
getAggregateStudioId,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
|
||||
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
|
||||
import { BulkUpdateDateInput } from "../Shared/DateInput";
|
||||
import { getDateError } from "src/utils/yup";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimSceneDataFragment[];
|
||||
onClose: (applied: boolean) => void;
|
||||
}
|
||||
|
||||
const sceneFields = [
|
||||
"code",
|
||||
"rating100",
|
||||
"details",
|
||||
"organized",
|
||||
"director",
|
||||
"date",
|
||||
];
|
||||
|
||||
export const EditScenesDialog: React.FC<IListOperationProps> = (
|
||||
props: IListOperationProps
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const [rating100, setRating] = useState<number>();
|
||||
const [studioId, setStudioId] = useState<string>();
|
||||
const [performerMode, setPerformerMode] =
|
||||
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
|
||||
const [performerIds, setPerformerIds] = useState<string[]>();
|
||||
const [existingPerformerIds, setExistingPerformerIds] = useState<string[]>();
|
||||
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
|
||||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [existingTagIds, setExistingTagIds] = useState<string[]>();
|
||||
const [groupMode, setGroupMode] = React.useState<GQL.BulkUpdateIdMode>(
|
||||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [groupIds, setGroupIds] = useState<string[]>();
|
||||
const [existingGroupIds, setExistingGroupIds] = useState<string[]>();
|
||||
const [organized, setOrganized] = useState<boolean | undefined>();
|
||||
|
||||
const [updateScenes] = useBulkSceneUpdate(getSceneInput());
|
||||
const [updateInput, setUpdateInput] = useState<GQL.BulkSceneUpdateInput>({
|
||||
ids: props.selected.map((scene) => {
|
||||
return scene.id;
|
||||
}),
|
||||
});
|
||||
|
||||
const [dateError, setDateError] = useState<string | undefined>();
|
||||
|
||||
const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
const [groupIds, setGroupIds] = useState<GQL.BulkUpdateIds>({
|
||||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
|
||||
const unsetDisabled = props.selected.length < 2;
|
||||
|
||||
const [updateScenes] = useBulkSceneUpdate();
|
||||
|
||||
// Network state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||
const aggregateState = useMemo(() => {
|
||||
const updateState: Partial<GQL.BulkSceneUpdateInput> = {};
|
||||
const state = props.selected;
|
||||
updateState.studio_id = getAggregateStudioId(props.selected);
|
||||
const updateTagIds = getAggregateTagIds(props.selected);
|
||||
const updatePerformerIds = getAggregatePerformerIds(props.selected);
|
||||
const updateGroupIds = getAggregateGroupIds(props.selected);
|
||||
let first = true;
|
||||
|
||||
state.forEach((scene: GQL.SlimSceneDataFragment) => {
|
||||
getAggregateStateObject(updateState, scene, sceneFields, first);
|
||||
first = false;
|
||||
});
|
||||
|
||||
return {
|
||||
state: updateState,
|
||||
tagIds: updateTagIds,
|
||||
performerIds: updatePerformerIds,
|
||||
groupIds: updateGroupIds,
|
||||
};
|
||||
}, [props.selected]);
|
||||
|
||||
// update initial state from aggregate
|
||||
useEffect(() => {
|
||||
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
|
||||
}, [aggregateState]);
|
||||
|
||||
useEffect(() => {
|
||||
setDateError(getDateError(updateInput.date ?? "", intl));
|
||||
}, [updateInput.date, intl]);
|
||||
|
||||
function setUpdateField(input: Partial<GQL.BulkSceneUpdateInput>) {
|
||||
setUpdateInput((current) => ({ ...current, ...input }));
|
||||
}
|
||||
|
||||
function getSceneInput(): GQL.BulkSceneUpdateInput {
|
||||
// need to determine what we are actually setting on each scene
|
||||
const aggregateRating = getAggregateRating(props.selected);
|
||||
const aggregateStudioId = getAggregateStudioId(props.selected);
|
||||
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
|
||||
const aggregateTagIds = getAggregateTagIds(props.selected);
|
||||
const aggregateGroupIds = getAggregateGroupIds(props.selected);
|
||||
|
||||
const sceneInput: GQL.BulkSceneUpdateInput = {
|
||||
ids: props.selected.map((scene) => {
|
||||
return scene.id;
|
||||
}),
|
||||
...updateInput,
|
||||
tag_ids: tagIds,
|
||||
performer_ids: performerIds,
|
||||
group_ids: groupIds,
|
||||
};
|
||||
|
||||
sceneInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
|
||||
sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
|
||||
|
||||
sceneInput.performer_ids = getAggregateInputIDs(
|
||||
performerMode,
|
||||
performerIds,
|
||||
aggregatePerformerIds
|
||||
// we don't have unset functionality for the rating star control
|
||||
// so need to determine if we are setting a rating or not
|
||||
sceneInput.rating100 = getAggregateInputValue(
|
||||
updateInput.rating100,
|
||||
aggregateState.state.rating100
|
||||
);
|
||||
sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
|
||||
sceneInput.group_ids = getAggregateInputIDs(
|
||||
groupMode,
|
||||
groupIds,
|
||||
aggregateGroupIds
|
||||
);
|
||||
|
||||
if (organized !== undefined) {
|
||||
sceneInput.organized = organized;
|
||||
}
|
||||
|
||||
return sceneInput;
|
||||
}
|
||||
|
|
@ -95,7 +123,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
async function onSave() {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateScenes();
|
||||
await updateScenes({ variables: { input: getSceneInput() } });
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
|
|
@ -109,145 +137,13 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
setIsUpdating(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const state = props.selected;
|
||||
let updateRating: number | undefined;
|
||||
let updateStudioID: string | undefined;
|
||||
let updatePerformerIds: string[] = [];
|
||||
let updateTagIds: string[] = [];
|
||||
let updateGroupIds: string[] = [];
|
||||
let updateOrganized: boolean | undefined;
|
||||
let first = true;
|
||||
|
||||
state.forEach((scene: GQL.SlimSceneDataFragment) => {
|
||||
const sceneRating = scene.rating100;
|
||||
const sceneStudioID = scene?.studio?.id;
|
||||
const scenePerformerIDs = (scene.performers ?? [])
|
||||
.map((p) => p.id)
|
||||
.sort();
|
||||
const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort();
|
||||
const sceneGroupIDs = (scene.groups ?? []).map((m) => m.group.id).sort();
|
||||
|
||||
if (first) {
|
||||
updateRating = sceneRating ?? undefined;
|
||||
updateStudioID = sceneStudioID;
|
||||
updatePerformerIds = scenePerformerIDs;
|
||||
updateTagIds = sceneTagIDs;
|
||||
updateGroupIds = sceneGroupIDs;
|
||||
first = false;
|
||||
updateOrganized = scene.organized;
|
||||
} else {
|
||||
if (sceneRating !== updateRating) {
|
||||
updateRating = undefined;
|
||||
}
|
||||
if (sceneStudioID !== updateStudioID) {
|
||||
updateStudioID = undefined;
|
||||
}
|
||||
if (!isEqual(scenePerformerIDs, updatePerformerIds)) {
|
||||
updatePerformerIds = [];
|
||||
}
|
||||
if (!isEqual(sceneTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
if (!isEqual(sceneGroupIDs, updateGroupIds)) {
|
||||
updateGroupIds = [];
|
||||
}
|
||||
if (scene.organized !== updateOrganized) {
|
||||
updateOrganized = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setRating(updateRating);
|
||||
setStudioId(updateStudioID);
|
||||
setExistingPerformerIds(updatePerformerIds);
|
||||
setExistingTagIds(updateTagIds);
|
||||
setExistingGroupIds(updateGroupIds);
|
||||
setOrganized(updateOrganized);
|
||||
}, [props.selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = organized === undefined;
|
||||
}
|
||||
}, [organized, checkboxRef]);
|
||||
|
||||
function renderMultiSelect(
|
||||
type: "performers" | "tags" | "groups",
|
||||
ids: string[] | undefined
|
||||
) {
|
||||
let mode = GQL.BulkUpdateIdMode.Add;
|
||||
let existingIds: string[] | undefined = [];
|
||||
switch (type) {
|
||||
case "performers":
|
||||
mode = performerMode;
|
||||
existingIds = existingPerformerIds;
|
||||
break;
|
||||
case "tags":
|
||||
mode = tagMode;
|
||||
existingIds = existingTagIds;
|
||||
break;
|
||||
case "groups":
|
||||
mode = groupMode;
|
||||
existingIds = existingGroupIds;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiSet
|
||||
type={type}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => {
|
||||
switch (type) {
|
||||
case "performers":
|
||||
setPerformerIds(itemIDs);
|
||||
break;
|
||||
case "tags":
|
||||
setTagIds(itemIDs);
|
||||
break;
|
||||
case "groups":
|
||||
setGroupIds(itemIDs);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
switch (type) {
|
||||
case "performers":
|
||||
setPerformerMode(newMode);
|
||||
break;
|
||||
case "tags":
|
||||
setTagMode(newMode);
|
||||
break;
|
||||
case "groups":
|
||||
setGroupMode(newMode);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
ids={ids ?? []}
|
||||
existingIds={existingIds ?? []}
|
||||
mode={mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function cycleOrganized() {
|
||||
if (organized) {
|
||||
setOrganized(undefined);
|
||||
} else if (organized === undefined) {
|
||||
setOrganized(false);
|
||||
} else {
|
||||
setOrganized(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "dialogs.edit_entity_title" },
|
||||
{ id: "dialogs.edit_entity_count_title" },
|
||||
{
|
||||
count: props?.selected?.length ?? 1,
|
||||
singularEntity: intl.formatMessage({ id: "scene" }),
|
||||
|
|
@ -258,6 +154,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
onClick: onSave,
|
||||
text: intl.formatMessage({ id: "actions.apply" }),
|
||||
}}
|
||||
disabled={isUpdating || !!dateError}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(false),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
|
|
@ -266,62 +163,121 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
isRunning={isUpdating}
|
||||
>
|
||||
<Form>
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<RatingSystem
|
||||
value={rating100}
|
||||
onSetRating={(value) => setRating(value ?? undefined)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "studio" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
||||
}
|
||||
ids={studioId ? [studioId] : []}
|
||||
isDisabled={isUpdating}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<BulkUpdateFormGroup name="rating">
|
||||
<RatingSystem
|
||||
value={updateInput.rating100}
|
||||
onSetRating={(value) =>
|
||||
setUpdateField({ rating100: value ?? undefined })
|
||||
}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="performers">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="performers" />
|
||||
</Form.Label>
|
||||
{renderMultiSelect("performers", performerIds)}
|
||||
</Form.Group>
|
||||
<BulkUpdateFormGroup name="scene_code">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.code}
|
||||
valueChanged={(newValue) => setUpdateField({ code: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="tags" />
|
||||
</Form.Label>
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</Form.Group>
|
||||
<BulkUpdateFormGroup name="date">
|
||||
<BulkUpdateDateInput
|
||||
value={updateInput.date}
|
||||
valueChanged={(newValue) => setUpdateField({ date: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
error={dateError}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="groups">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="groups" />
|
||||
</Form.Label>
|
||||
{renderMultiSelect("groups", groupIds)}
|
||||
</Form.Group>
|
||||
<BulkUpdateFormGroup name="director">
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.director}
|
||||
valueChanged={(newValue) =>
|
||||
setUpdateField({ director: newValue })
|
||||
}
|
||||
unsetDisabled={unsetDisabled}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="studio">
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setUpdateField({
|
||||
studio_id: items.length > 0 ? items[0]?.id : undefined,
|
||||
})
|
||||
}
|
||||
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
|
||||
isDisabled={isUpdating}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="performers" inline={false}>
|
||||
<MultiSet
|
||||
type={"performers"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => {
|
||||
setPerformerIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setPerformerIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={performerIds.ids ?? []}
|
||||
existingIds={aggregateState.performerIds}
|
||||
mode={performerIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="groups" inline={false}>
|
||||
<MultiSet
|
||||
type={"groups"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => {
|
||||
setGroupIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setGroupIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={groupIds.ids ?? []}
|
||||
existingIds={aggregateState.groupIds}
|
||||
mode={groupIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="tags" inline={false}>
|
||||
<MultiSet
|
||||
type={"tags"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => {
|
||||
setTagIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setTagIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={tagIds.ids ?? []}
|
||||
existingIds={aggregateState.tagIds}
|
||||
mode={tagIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<BulkUpdateFormGroup name="details" inline={false}>
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.details}
|
||||
valueChanged={(newValue) => setUpdateField({ details: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
as="textarea"
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="organized">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
<IndeterminateCheckbox
|
||||
label={intl.formatMessage({ id: "organized" })}
|
||||
checked={organized}
|
||||
ref={checkboxRef}
|
||||
onChange={() => cycleOrganized()}
|
||||
setChecked={(checked) => setUpdateField({ organized: checked })}
|
||||
checked={updateInput.organized ?? undefined}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -352,6 +352,39 @@ const SceneCardOverlays = PatchComponent(
|
|||
}
|
||||
);
|
||||
|
||||
interface ISceneSpecsOverlay {
|
||||
scene: GQL.SlimSceneDataFragment;
|
||||
}
|
||||
|
||||
export const SceneSpecsOverlay: React.FC<ISceneSpecsOverlay> = PatchComponent(
|
||||
"SceneCard.SceneSpecs",
|
||||
({ scene }) => {
|
||||
const file = scene.files?.[0];
|
||||
if (!file) return null;
|
||||
return (
|
||||
<div className="scene-specs-overlay">
|
||||
<span className="overlay-filesize extra-scene-info">
|
||||
<FileSize size={file.size} />
|
||||
</span>
|
||||
{file.width && file.height ? (
|
||||
<span className="overlay-resolution">
|
||||
{TextUtils.resolution(file.width, file.height)}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{file.duration > 0 ? (
|
||||
<span className="overlay-duration">
|
||||
{TextUtils.secondsToTimestamp(file.duration)}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const SceneCardImage = PatchComponent(
|
||||
"SceneCard.Image",
|
||||
(props: ISceneCardProps) => {
|
||||
|
|
@ -364,35 +397,6 @@ const SceneCardImage = PatchComponent(
|
|||
[props.scene]
|
||||
);
|
||||
|
||||
function maybeRenderSceneSpecsOverlay() {
|
||||
return (
|
||||
<div className="scene-specs-overlay">
|
||||
{file?.size !== undefined ? (
|
||||
<span className="overlay-filesize extra-scene-info">
|
||||
<FileSize size={file.size} />
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{file?.width && file?.height ? (
|
||||
<span className="overlay-resolution">
|
||||
{" "}
|
||||
{TextUtils.resolution(file?.width, file?.height)}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{(file?.duration ?? 0) >= 1 ? (
|
||||
<span className="overlay-duration">
|
||||
{TextUtils.secondsToTimestamp(file?.duration ?? 0)}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderInteractiveSpeedOverlay() {
|
||||
return (
|
||||
<div className="scene-interactive-speed-overlay">
|
||||
|
|
@ -432,7 +436,7 @@ const SceneCardImage = PatchComponent(
|
|||
disabled={props.selecting}
|
||||
/>
|
||||
<RatingBanner rating={props.scene.rating100} />
|
||||
{maybeRenderSceneSpecsOverlay()}
|
||||
<SceneSpecsOverlay scene={props.scene} />
|
||||
{maybeRenderInteractiveSpeedOverlay()}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { StringListSelect, GallerySelect } from "../Shared/Select";
|
||||
import { GallerySelect } from "../Shared/Select";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import TextUtils from "src/utils/text";
|
||||
|
|
@ -26,7 +26,7 @@ import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
|
|||
import { clone, uniq } from "lodash-es";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { IHasStoredID, sortStoredIdObjects } from "src/utils/data";
|
||||
import { sortStoredIdObjects, uniqIDStoredIDs } from "src/utils/data";
|
||||
import {
|
||||
CustomFieldScrapeResults,
|
||||
ObjectListScrapeResult,
|
||||
|
|
@ -41,14 +41,7 @@ import {
|
|||
ScrapedTagsRow,
|
||||
} from "../Shared/ScrapeDialog/ScrapedObjectsRow";
|
||||
import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect";
|
||||
|
||||
interface IStashIDsField {
|
||||
values: GQL.StashId[];
|
||||
}
|
||||
|
||||
const StashIDsField: React.FC<IStashIDsField> = ({ values }) => {
|
||||
return <StringListSelect value={values.map((v) => v.stash_id)} />;
|
||||
};
|
||||
import { StashIDsField } from "../Shared/StashID";
|
||||
|
||||
type MergeOptions = {
|
||||
values: GQL.SceneUpdateInput;
|
||||
|
|
@ -132,12 +125,6 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
|
|||
return ret;
|
||||
}
|
||||
|
||||
function uniqIDStoredIDs<T extends IHasStoredID>(objs: T[]) {
|
||||
return objs.filter((o, i) => {
|
||||
return objs.findIndex((oo) => oo.stored_id === o.stored_id) === i;
|
||||
});
|
||||
}
|
||||
|
||||
const [performers, setPerformers] = useState<
|
||||
ObjectListScrapeResult<GQL.ScrapedPerformer>
|
||||
>(
|
||||
|
|
@ -604,6 +591,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
|
|||
}
|
||||
newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
|
||||
onChange={(value) => setStashIDs(value)}
|
||||
alwaysShow={
|
||||
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length
|
||||
}
|
||||
/>
|
||||
<ScrapedImageRow
|
||||
field="cover_image"
|
||||
|
|
|
|||
|
|
@ -190,6 +190,10 @@ textarea.scene-description {
|
|||
right: 0.7rem;
|
||||
}
|
||||
|
||||
.scene-specs-overlay > span:not(:last-child) {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.scene-interactive-speed-overlay {
|
||||
left: 0.7rem;
|
||||
}
|
||||
|
|
@ -200,7 +204,6 @@ textarea.scene-description {
|
|||
|
||||
.overlay-resolution {
|
||||
font-weight: 900;
|
||||
margin-right: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ export const SettingsInterfacePanel: React.FC = PatchComponent(
|
|||
onChange={(v) => saveInterface({ language: v })}
|
||||
>
|
||||
<option value="af-ZA">Afrikaans (Preview)</option>
|
||||
<option value="ar">Arabic (Preview)</option>
|
||||
<option value="bg-BG">Bulgarian (Preview)</option>
|
||||
<option value="bn-BD">বাংলা (বাংলাদেশ) (Preview)</option>
|
||||
<option value="ca-ES">Catalan (Preview)</option>
|
||||
|
|
|
|||
|
|
@ -145,6 +145,13 @@ const CleanOptions: React.FC<ICleanOptions> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<BooleanSetting
|
||||
id="clean-ignore-zip-contents"
|
||||
checked={options.ignoreZipFileContents ?? false}
|
||||
headingID="config.tasks.clean_ignore_zip_contents"
|
||||
subHeadingID="config.tasks.clean_ignore_zip_contents_desc"
|
||||
onChange={(v) => setOptions({ ignoreZipFileContents: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="clean-dryrun"
|
||||
checked={options.dryRun}
|
||||
|
|
|
|||
242
ui/v2.5/src/components/Shared/BatchModals.tsx
Normal file
242
ui/v2.5/src/components/Shared/BatchModals.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IEntityWithStashIDs {
|
||||
stash_ids: { endpoint: string }[];
|
||||
}
|
||||
|
||||
interface IBatchUpdateModalProps {
|
||||
entities: IEntityWithStashIDs[];
|
||||
isIdle: boolean;
|
||||
selectedEndpoint: { endpoint: string; index: number };
|
||||
allCount: number | undefined;
|
||||
onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;
|
||||
onRefreshChange?: (refresh: boolean) => void;
|
||||
batchAddParents: boolean;
|
||||
setBatchAddParents: (addParents: boolean) => void;
|
||||
close: () => void;
|
||||
localePrefix: string;
|
||||
entityName: string;
|
||||
countVariableName: string;
|
||||
}
|
||||
|
||||
export const BatchUpdateModal: React.FC<IBatchUpdateModalProps> = ({
|
||||
entities,
|
||||
isIdle,
|
||||
selectedEndpoint,
|
||||
allCount,
|
||||
onBatchUpdate,
|
||||
onRefreshChange,
|
||||
batchAddParents,
|
||||
setBatchAddParents,
|
||||
close,
|
||||
localePrefix,
|
||||
entityName,
|
||||
countVariableName,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [queryAll, setQueryAll] = useState(false);
|
||||
const [refresh, setRefreshState] = useState(false);
|
||||
|
||||
const setRefresh = (value: boolean) => {
|
||||
setRefreshState(value);
|
||||
onRefreshChange?.(value);
|
||||
};
|
||||
|
||||
const entityCount = useMemo(() => {
|
||||
const filteredStashIDs = entities.map((e) =>
|
||||
e.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)
|
||||
);
|
||||
|
||||
return queryAll
|
||||
? allCount
|
||||
: filteredStashIDs.filter((s) =>
|
||||
refresh ? s.length > 0 : s.length === 0
|
||||
).length;
|
||||
}, [queryAll, refresh, entities, allCount, selectedEndpoint.endpoint]);
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faTags}
|
||||
header={intl.formatMessage({
|
||||
id: `${localePrefix}.update_${entityName}s`,
|
||||
})}
|
||||
accept={{
|
||||
text: intl.formatMessage({
|
||||
id: `${localePrefix}.update_${entityName}s`,
|
||||
}),
|
||||
onClick: () => onBatchUpdate(queryAll, refresh),
|
||||
}}
|
||||
cancel={{
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "danger",
|
||||
onClick: () => close(),
|
||||
}}
|
||||
disabled={!isIdle}
|
||||
>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<h6>
|
||||
<FormattedMessage id={`${localePrefix}.${entityName}_selection`} />
|
||||
</h6>
|
||||
</Form.Label>
|
||||
<Form.Check
|
||||
id="query-page"
|
||||
type="radio"
|
||||
name={`${entityName}-query`}
|
||||
label={<FormattedMessage id={`${localePrefix}.current_page`} />}
|
||||
checked={!queryAll}
|
||||
onChange={() => setQueryAll(false)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="query-all"
|
||||
type="radio"
|
||||
name={`${entityName}-query`}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.query_all_${entityName}s_in_the_database`,
|
||||
})}
|
||||
checked={queryAll}
|
||||
onChange={() => setQueryAll(true)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<h6>
|
||||
<FormattedMessage id={`${localePrefix}.tag_status`} />
|
||||
</h6>
|
||||
</Form.Label>
|
||||
<Form.Check
|
||||
id={`untagged-${entityName}s`}
|
||||
type="radio"
|
||||
name={`${entityName}-refresh`}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.untagged_${entityName}s`,
|
||||
})}
|
||||
checked={!refresh}
|
||||
onChange={() => setRefresh(false)}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage
|
||||
id={`${localePrefix}.updating_untagged_${entityName}s_description`}
|
||||
/>
|
||||
</Form.Text>
|
||||
<Form.Check
|
||||
id={`tagged-${entityName}s`}
|
||||
type="radio"
|
||||
name={`${entityName}-refresh`}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.refresh_tagged_${entityName}s`,
|
||||
})}
|
||||
checked={refresh}
|
||||
onChange={() => setRefresh(true)}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage
|
||||
id={`${localePrefix}.refreshing_will_update_the_data`}
|
||||
/>
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<div className="mt-4">
|
||||
<Form.Check
|
||||
id="add-parent"
|
||||
checked={batchAddParents}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.create_or_tag_parent_${entityName}s`,
|
||||
})}
|
||||
onChange={() => setBatchAddParents(!batchAddParents)}
|
||||
/>
|
||||
</div>
|
||||
<b>
|
||||
<FormattedMessage
|
||||
id={`${localePrefix}.number_of_${entityName}s_will_be_processed`}
|
||||
values={{
|
||||
[countVariableName]: entityCount,
|
||||
}}
|
||||
/>
|
||||
</b>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
interface IBatchAddModalProps {
|
||||
isIdle: boolean;
|
||||
onBatchAdd: (input: string) => void;
|
||||
batchAddParents: boolean;
|
||||
setBatchAddParents: (addParents: boolean) => void;
|
||||
close: () => void;
|
||||
localePrefix: string;
|
||||
entityName: string;
|
||||
}
|
||||
|
||||
export const BatchAddModal: React.FC<IBatchAddModalProps> = ({
|
||||
isIdle,
|
||||
onBatchAdd,
|
||||
batchAddParents,
|
||||
setBatchAddParents,
|
||||
close,
|
||||
localePrefix,
|
||||
entityName,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faStar}
|
||||
header={intl.formatMessage({
|
||||
id: `${localePrefix}.add_new_${entityName}s`,
|
||||
})}
|
||||
accept={{
|
||||
text: intl.formatMessage({
|
||||
id: `${localePrefix}.add_new_${entityName}s`,
|
||||
}),
|
||||
onClick: () => {
|
||||
if (inputRef.current) {
|
||||
onBatchAdd(inputRef.current.value);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
}}
|
||||
cancel={{
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "danger",
|
||||
onClick: () => close(),
|
||||
}}
|
||||
disabled={!isIdle}
|
||||
>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
as="textarea"
|
||||
ref={inputRef}
|
||||
placeholder={intl.formatMessage({
|
||||
id: `${localePrefix}.${entityName}_names_or_stashids_separated_by_comma`,
|
||||
})}
|
||||
rows={6}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage
|
||||
id={`${localePrefix}.any_names_entered_will_be_queried`}
|
||||
/>
|
||||
</Form.Text>
|
||||
<div className="mt-2">
|
||||
<Form.Check
|
||||
id="add-parent"
|
||||
checked={batchAddParents}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.create_or_tag_parent_${entityName}s`,
|
||||
})}
|
||||
onChange={() => setBatchAddParents(!batchAddParents)}
|
||||
/>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
89
ui/v2.5/src/components/Shared/BulkUpdate.tsx
Normal file
89
ui/v2.5/src/components/Shared/BulkUpdate.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { faBan } from "@fortawesome/free-solid-svg-icons";
|
||||
import React from "react";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
FormControlProps,
|
||||
InputGroup,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "./Icon";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
|
||||
interface IBulkUpdateTextInputProps extends Omit<FormControlProps, "value"> {
|
||||
valueChanged: (value: string | null | undefined) => void;
|
||||
value: string | null | undefined;
|
||||
unsetDisabled?: boolean;
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
export const BulkUpdateTextInput: React.FC<IBulkUpdateTextInputProps> = ({
|
||||
valueChanged,
|
||||
unsetDisabled,
|
||||
...props
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const value = props.value === null ? "" : props.value ?? undefined;
|
||||
const unset = value === undefined;
|
||||
|
||||
const placeholderValue = unset
|
||||
? `<${intl.formatMessage({ id: "existing_value" })}>`
|
||||
: value === ""
|
||||
? `<${intl.formatMessage({ id: "empty_value" })}>`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<InputGroup className="bulk-update-text-input">
|
||||
<Form.Control
|
||||
{...props}
|
||||
className="text-input"
|
||||
type="text"
|
||||
as={props.as}
|
||||
value={value ?? ""}
|
||||
placeholder={placeholderValue}
|
||||
onChange={(event) => valueChanged(event.currentTarget.value)}
|
||||
/>
|
||||
<InputGroup.Append>
|
||||
{!unsetDisabled ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => valueChanged(undefined)}
|
||||
title={intl.formatMessage({ id: "actions.unset" })}
|
||||
disabled={unset}
|
||||
>
|
||||
<Icon icon={faBan} />
|
||||
</Button>
|
||||
) : undefined}
|
||||
</InputGroup.Append>
|
||||
</InputGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export const BulkUpdateFormGroup: React.FC<{
|
||||
name: string;
|
||||
messageId?: string;
|
||||
inline?: boolean;
|
||||
}> = ({ name, messageId = name, inline = true, children }) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<Form.Group controlId={name} data-field={name} as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: <FormattedMessage id={messageId} />,
|
||||
})}
|
||||
<Col xs={9}>{children}</Col>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group controlId={name} data-field={name}>
|
||||
<Form.Label>
|
||||
<FormattedMessage id={messageId} />
|
||||
</Form.Label>
|
||||
{children}
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { faBan } from "@fortawesome/free-solid-svg-icons";
|
||||
import React from "react";
|
||||
import { Button, Form, FormControlProps, InputGroup } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface IBulkUpdateTextInputProps extends FormControlProps {
|
||||
valueChanged: (value: string | undefined) => void;
|
||||
unsetDisabled?: boolean;
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
export const BulkUpdateTextInput: React.FC<IBulkUpdateTextInputProps> = ({
|
||||
valueChanged,
|
||||
unsetDisabled,
|
||||
...props
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const unsetClassName = props.value === undefined ? "unset" : "";
|
||||
|
||||
return (
|
||||
<InputGroup className={`bulk-update-text-input ${unsetClassName}`}>
|
||||
<Form.Control
|
||||
{...props}
|
||||
className="input-control"
|
||||
type="text"
|
||||
as={props.as}
|
||||
value={props.value ?? ""}
|
||||
placeholder={
|
||||
props.value === undefined
|
||||
? `<${intl.formatMessage({ id: "existing_value" })}>`
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) => valueChanged(event.currentTarget.value)}
|
||||
/>
|
||||
{!unsetDisabled ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => valueChanged(undefined)}
|
||||
title={intl.formatMessage({ id: "actions.unset" })}
|
||||
>
|
||||
<Icon icon={faBan} />
|
||||
</Button>
|
||||
) : undefined}
|
||||
</InputGroup>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,14 +8,20 @@ import { Icon } from "./Icon";
|
|||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { useIntl } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { faBan, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IProps {
|
||||
groupClassName?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
value: string;
|
||||
isTime?: boolean;
|
||||
onValueChange(value: string): void;
|
||||
placeholder?: string;
|
||||
placeholderOverride?: string;
|
||||
error?: string;
|
||||
appendBefore?: React.ReactNode;
|
||||
appendAfter?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ShowPickerButton = forwardRef<
|
||||
|
|
@ -32,6 +38,11 @@ const ShowPickerButton = forwardRef<
|
|||
const _DateInput: React.FC<IProps> = (props: IProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
groupClassName = "date-input-group",
|
||||
className = "date-input text-input",
|
||||
} = props;
|
||||
|
||||
const date = useMemo(() => {
|
||||
const toDate = props.isTime
|
||||
? TextUtils.stringToFuzzyDateTime
|
||||
|
|
@ -70,34 +81,108 @@ const _DateInput: React.FC<IProps> = (props: IProps) => {
|
|||
}
|
||||
}
|
||||
|
||||
const placeholderText = intl.formatMessage({
|
||||
const formatHint = intl.formatMessage({
|
||||
id: props.isTime ? "datetime_format" : "date_format",
|
||||
});
|
||||
|
||||
const placeholderText = props.placeholder
|
||||
? `${props.placeholder} (${formatHint})`
|
||||
: formatHint;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InputGroup hasValidation>
|
||||
<Form.Control
|
||||
className="date-input text-input"
|
||||
disabled={props.disabled}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onValueChange(e.currentTarget.value)}
|
||||
placeholder={
|
||||
!props.disabled
|
||||
? props.placeholder
|
||||
? `${props.placeholder} (${placeholderText})`
|
||||
: placeholderText
|
||||
: undefined
|
||||
}
|
||||
isInvalid={!!props.error}
|
||||
/>
|
||||
<InputGroup.Append>{maybeRenderButton()}</InputGroup.Append>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{props.error}
|
||||
</Form.Control.Feedback>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<InputGroup hasValidation className={groupClassName}>
|
||||
<Form.Control
|
||||
className={className}
|
||||
disabled={props.disabled}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onValueChange(e.currentTarget.value)}
|
||||
placeholder={
|
||||
!props.disabled
|
||||
? props.placeholderOverride ?? placeholderText
|
||||
: undefined
|
||||
}
|
||||
isInvalid={!!props.error}
|
||||
/>
|
||||
<InputGroup.Append>
|
||||
{props.appendBefore}
|
||||
{maybeRenderButton()}
|
||||
{props.appendAfter}
|
||||
</InputGroup.Append>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{props.error}
|
||||
</Form.Control.Feedback>
|
||||
</InputGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export const DateInput = PatchComponent("DateInput", _DateInput);
|
||||
|
||||
interface IBulkUpdateDateInputProps
|
||||
extends Omit<IProps, "onValueChange" | "value"> {
|
||||
value: string | null | undefined;
|
||||
valueChanged: (value: string | null | undefined) => void;
|
||||
unsetDisabled?: boolean;
|
||||
as?: React.ElementType;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const BulkUpdateDateInput: React.FC<IBulkUpdateDateInputProps> = ({
|
||||
valueChanged,
|
||||
unsetDisabled,
|
||||
...props
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const unset = props.value === undefined;
|
||||
|
||||
const unsetButton = !unsetDisabled ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => valueChanged(undefined)}
|
||||
title={intl.formatMessage({ id: "actions.unset" })}
|
||||
disabled={unset}
|
||||
>
|
||||
<Icon icon={faBan} />
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
const clearButton =
|
||||
props.value !== null ? (
|
||||
<Button
|
||||
className="minimal"
|
||||
variant="secondary"
|
||||
onClick={() => valueChanged(null)}
|
||||
title={intl.formatMessage({ id: "actions.clear" })}
|
||||
>
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
const placeholderValue =
|
||||
props.value === null
|
||||
? `<${intl.formatMessage({ id: "empty_value" })}>`
|
||||
: props.value === undefined
|
||||
? `<${intl.formatMessage({ id: "existing_value" })}>`
|
||||
: undefined;
|
||||
|
||||
function outValue(v: string | undefined) {
|
||||
if (v === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
return (
|
||||
<DateInput
|
||||
{...props}
|
||||
value={props.value ?? ""}
|
||||
placeholderOverride={placeholderValue}
|
||||
onValueChange={(v) => valueChanged(outValue(v))}
|
||||
groupClassName="bulk-update-date-input"
|
||||
className="date-input text-input"
|
||||
appendBefore={clearButton}
|
||||
appendAfter={unsetButton}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ const SelectComponent = <T, IsMulti extends boolean>(
|
|||
) => {
|
||||
const {
|
||||
selectedOptions,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
creatable = false,
|
||||
components,
|
||||
|
|
@ -101,10 +100,7 @@ const SelectComponent = <T, IsMulti extends boolean>(
|
|||
};
|
||||
|
||||
return creatable ? (
|
||||
<AsyncCreatableSelect
|
||||
{...componentProps}
|
||||
isDisabled={isLoading || isDisabled}
|
||||
/>
|
||||
<AsyncCreatableSelect {...componentProps} isDisabled={isDisabled} />
|
||||
) : (
|
||||
<AsyncSelect {...componentProps} />
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ import { PerformerIDSelect } from "../Performers/PerformerSelect";
|
|||
import { StudioIDSelect } from "../Studios/StudioSelect";
|
||||
import { TagIDSelect } from "../Tags/TagSelect";
|
||||
import { GroupIDSelect } from "../Groups/GroupSelect";
|
||||
import { SceneIDSelect } from "../Scenes/SceneSelect";
|
||||
|
||||
interface IMultiSetProps {
|
||||
type: "performers" | "studios" | "tags" | "groups" | "galleries";
|
||||
type: "performers" | "studios" | "tags" | "groups" | "galleries" | "scenes";
|
||||
existingIds?: string[];
|
||||
ids?: string[];
|
||||
mode: GQL.BulkUpdateIdMode;
|
||||
|
|
@ -89,6 +90,17 @@ const Select: React.FC<IMultiSetProps> = (props) => {
|
|||
menuPortalTarget={props.menuPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "scenes":
|
||||
return (
|
||||
<SceneIDSelect
|
||||
isDisabled={disabled}
|
||||
isMulti
|
||||
isClearable={false}
|
||||
onSelect={onUpdate}
|
||||
ids={props.ids ?? []}
|
||||
menuPortalTarget={props.menuPortalTarget}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<FilterSelect
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ interface IScrapedRowProps<T> extends IScrapedFieldProps<T> {
|
|||
newField: React.ReactNode;
|
||||
onChange: (value: ScrapeResult<T>) => void;
|
||||
newValues?: React.ReactNode;
|
||||
alwaysShow?: boolean;
|
||||
}
|
||||
|
||||
export const ScrapeDialogRow = <T,>(props: IScrapedRowProps<T>) => {
|
||||
|
|
@ -51,7 +52,7 @@ export const ScrapeDialogRow = <T,>(props: IScrapedRowProps<T>) => {
|
|||
props.onChange(ret);
|
||||
}
|
||||
|
||||
if (!props.result.scraped && !props.newValues) {
|
||||
if (!props.result.scraped && !props.newValues && !props.alwaysShow) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,3 +31,21 @@ export const StashIDPill: React.FC<{
|
|||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStashIDsField {
|
||||
values: StashId[];
|
||||
}
|
||||
|
||||
export const StashIDsField: React.FC<IStashIDsField> = ({ values }) => {
|
||||
if (!values.length) return null;
|
||||
|
||||
return (
|
||||
<ul className="pl-0 mw-100">
|
||||
{values.map((v) => (
|
||||
<li key={v.stash_id} className="row no-gutters">
|
||||
<StashIDPill linkType="scenes" stashID={v} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -494,30 +494,10 @@ button.collapse-button {
|
|||
}
|
||||
}
|
||||
|
||||
.bulk-update-text-input {
|
||||
button {
|
||||
background-color: $secondary;
|
||||
color: $text-muted;
|
||||
font-size: $btn-font-size-sm;
|
||||
margin: $btn-padding-y $btn-padding-x;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 4;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active,
|
||||
&:not(:disabled):not(.disabled):active,
|
||||
&:not(:disabled):not(.disabled):active:focus {
|
||||
background-color: $secondary;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.unset button {
|
||||
visibility: hidden;
|
||||
.bulk-update-date-input {
|
||||
.react-datepicker-wrapper .btn {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -666,10 +646,11 @@ div.react-datepicker {
|
|||
}
|
||||
|
||||
.stash-id-pill {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
font-size: 90%;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
max-width: 100%;
|
||||
padding-bottom: 0.25em;
|
||||
padding-top: 0.25em;
|
||||
text-align: center;
|
||||
|
|
@ -685,12 +666,15 @@ div.react-datepicker {
|
|||
span {
|
||||
background-color: $primary;
|
||||
border-radius: 0.25rem 0 0 0.25rem;
|
||||
flex-shrink: 0;
|
||||
min-width: 5em;
|
||||
}
|
||||
|
||||
a {
|
||||
background-color: $secondary;
|
||||
border-radius: 0 0.25rem 0.25rem 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1264,3 +1248,8 @@ input[type="range"].double-range-slider-max {
|
|||
margin-left: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
// general styling for appended minimal button to input group
|
||||
.text-input + .input-group-append .btn.minimal {
|
||||
background-color: $textfield-bg;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Col, Form, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useBulkStudioUpdate } from "src/core/StashService";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
|
|
@ -13,9 +13,8 @@ import {
|
|||
getAggregateStateObject,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
|
||||
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
|
||||
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import { StudioSelect } from "../Shared/Select";
|
||||
|
||||
interface IListOperationProps {
|
||||
|
|
@ -47,6 +46,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
|
|||
mode: GQL.BulkUpdateIdMode.Add,
|
||||
});
|
||||
|
||||
const unsetDisabled = props.selected.length < 2;
|
||||
|
||||
const [updateStudios] = useBulkStudioUpdate();
|
||||
|
||||
// Network state
|
||||
|
|
@ -126,27 +127,6 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
|
|||
setIsUpdating(false);
|
||||
}
|
||||
|
||||
function renderTextField(
|
||||
name: string,
|
||||
value: string | undefined | null,
|
||||
setter: (newValue: string | undefined) => void,
|
||||
area: boolean = false
|
||||
) {
|
||||
return (
|
||||
<Form.Group controlId={name}>
|
||||
<Form.Label>
|
||||
<FormattedMessage id={name} />
|
||||
</Form.Label>
|
||||
<BulkUpdateTextInput
|
||||
value={value === null ? "" : value ?? undefined}
|
||||
valueChanged={(newValue) => setter(newValue)}
|
||||
unsetDisabled={props.selected.length < 2}
|
||||
as={area ? "textarea" : undefined}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<ModalComponent
|
||||
|
|
@ -154,8 +134,12 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
|
|||
show
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "actions.edit_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "studios" }) }
|
||||
{ id: "dialogs.edit_entity_count_title" },
|
||||
{
|
||||
count: props?.selected?.length ?? 1,
|
||||
singularEntity: intl.formatMessage({ id: "studio" }),
|
||||
pluralEntity: intl.formatMessage({ id: "studios" }),
|
||||
}
|
||||
)}
|
||||
accept={{
|
||||
onClick: onSave,
|
||||
|
|
@ -168,11 +152,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
|
|||
}}
|
||||
isRunning={isUpdating}
|
||||
>
|
||||
<Form.Group controlId="parent-studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "parent_studio" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form>
|
||||
<BulkUpdateFormGroup name="parent-studio" messageId="parent_studio">
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setUpdateField({
|
||||
|
|
@ -183,13 +164,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
|
|||
isDisabled={isUpdating}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
</BulkUpdateFormGroup>
|
||||
<BulkUpdateFormGroup name="rating">
|
||||
<RatingSystem
|
||||
value={updateInput.rating100}
|
||||
onSetRating={(value) =>
|
||||
|
|
@ -197,9 +173,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
|
|||
}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="favorite">
|
||||
<IndeterminateCheckbox
|
||||
setChecked={(checked) => setUpdateField({ favorite: checked })}
|
||||
|
|
@ -208,30 +183,31 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
|
|||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="tags" />
|
||||
</Form.Label>
|
||||
<BulkUpdateFormGroup name="tags" inline={false}>
|
||||
<MultiSet
|
||||
type="tags"
|
||||
type={"tags"}
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => setTagIds((v) => ({ ...v, ids: itemIDs }))}
|
||||
onSetMode={(newMode) =>
|
||||
setTagIds((v) => ({ ...v, mode: newMode }))
|
||||
}
|
||||
existingIds={aggregateState.tagIds ?? []}
|
||||
onUpdate={(itemIDs) => {
|
||||
setTagIds((c) => ({ ...c, ids: itemIDs }));
|
||||
}}
|
||||
onSetMode={(newMode) => {
|
||||
setTagIds((c) => ({ ...c, mode: newMode }));
|
||||
}}
|
||||
ids={tagIds.ids ?? []}
|
||||
existingIds={aggregateState.tagIds}
|
||||
mode={tagIds.mode}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
{renderTextField(
|
||||
"details",
|
||||
updateInput.details,
|
||||
(newValue) => setUpdateField({ details: newValue }),
|
||||
true
|
||||
)}
|
||||
<BulkUpdateFormGroup name="details" inline={false}>
|
||||
<BulkUpdateTextInput
|
||||
value={updateInput.details}
|
||||
valueChanged={(newValue) => setUpdateField({ details: newValue })}
|
||||
unsetDisabled={unsetDisabled}
|
||||
as="textarea"
|
||||
/>
|
||||
</BulkUpdateFormGroup>
|
||||
|
||||
<Form.Group controlId="ignore-auto-tags">
|
||||
<IndeterminateCheckbox
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ import {
|
|||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faCheck,
|
||||
faExternalLinkAlt,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { ExternalLink } from "../Shared/ExternalLink";
|
||||
import { StashIDPill } from "../Shared/StashID";
|
||||
|
||||
interface IPerformerModalProps {
|
||||
performer: GQL.ScrapedScenePerformerDataFragment;
|
||||
|
|
@ -208,15 +208,13 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
|||
|
||||
function maybeRenderStashBoxLink() {
|
||||
const base = endpoint?.match(/https?:\/\/.*?\//)?.[0];
|
||||
if (!base) return;
|
||||
if (!base || !performer.remote_site_id) return;
|
||||
|
||||
return (
|
||||
<h6 className="mt-2">
|
||||
<ExternalLink href={`${base}performers/${performer.remote_site_id}`}>
|
||||
<FormattedMessage id="stashbox.source" />
|
||||
<Icon icon={faExternalLinkAlt} className="ml-2" />
|
||||
</ExternalLink>
|
||||
</h6>
|
||||
<StashIDPill
|
||||
linkType="performers"
|
||||
stashID={{ endpoint: endpoint, stash_id: performer.remote_site_id }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export const initialConfig: ITaggerConfig = {
|
|||
excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS,
|
||||
excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS,
|
||||
createParentStudios: true,
|
||||
createParentTags: true,
|
||||
};
|
||||
|
||||
export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata";
|
||||
|
|
@ -56,6 +57,7 @@ export interface ITaggerConfig {
|
|||
excludedStudioFields?: string[];
|
||||
excludedTagFields?: string[];
|
||||
createParentStudios: boolean;
|
||||
createParentTags: boolean;
|
||||
}
|
||||
|
||||
export const PERFORMER_FIELDS = [
|
||||
|
|
@ -85,4 +87,4 @@ export const PERFORMER_FIELDS = [
|
|||
];
|
||||
|
||||
export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"];
|
||||
export const TAG_FIELDS = ["name", "description", "aliases"];
|
||||
export const TAG_FIELDS = ["name", "description", "aliases", "parent_tags"];
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import { StashIDPill } from "src/components/Shared/StashID";
|
|||
import { PerformerLink, TagLink } from "src/components/Shared/TagLink";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { parsePath, prepareQueryString } from "src/components/Tagger/utils";
|
||||
import { ScenePreview } from "src/components/Scenes/SceneCard";
|
||||
import {
|
||||
ScenePreview,
|
||||
SceneSpecsOverlay,
|
||||
} from "src/components/Scenes/SceneCard";
|
||||
import { TaggerStateContext } from "../context";
|
||||
import {
|
||||
faChevronDown,
|
||||
|
|
@ -271,6 +274,7 @@ export const TaggerScene: React.FC<PropsWithChildren<ITaggerScene>> = ({
|
|||
vttPath={scene.paths.vtt ?? undefined}
|
||||
onScrubberClick={onScrubberClick}
|
||||
/>
|
||||
<SceneSpecsOverlay scene={scene} />
|
||||
{maybeRenderSpriteIcon()}
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue