diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index c4e916e65..1b95dfc6c 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -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 } diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 323fb8741..c7d880266 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -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! } diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index a1c878923..8610f39dc 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -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 diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index e2601150b..6ad620dbe 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -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! } diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 97a80b94f..bf17298da 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -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)" diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 0818e61c2..799b5cd6e 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -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 diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index b8810aa79..fafd928f7 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -73,6 +73,7 @@ type ScrapedTag { name: String! description: String alias_list: [String!] + parent: ScrapedTag "Remote site ID, if applicable" remote_site_id: String } diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index edd44c835..ebaf05648 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -31,6 +31,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } fragment MeasurementsFragment on Measurements { diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index b770f5801..261a98ff3 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -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 } diff --git a/internal/api/resolver_mutation_group.go b/internal/api/resolver_mutation_group.go index dff5a6c1e..6c986c4da 100644 --- a/internal/api/resolver_mutation_group.go +++ b/internal/api/resolver_mutation_group.go @@ -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") diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 653348304..6f88c54ca 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -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") diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index c9e840519..76938e9ff 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -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, }) diff --git a/internal/manager/scan_stashignore_test.go b/internal/manager/scan_stashignore_test.go index fafd246e8..2745ff970 100644 --- a/internal/manager/scan_stashignore_test.go +++ b/internal/manager/scan_stashignore_test.go @@ -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", diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index 9a20b3990..67b7038b6 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -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 } diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index a006abbf8..53e6944b5 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -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 } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 97c766010..ec17fac06 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -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 { diff --git a/pkg/file/clean.go b/pkg/file/clean.go index 53b2e0612..369600f4c 100644 --- a/pkg/file/clean.go +++ b/pkg/file/clean.go @@ -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) { diff --git a/pkg/file/folder_rename_detect.go b/pkg/file/folder_rename_detect.go index cfae7e4fb..d45593b28 100644 --- a/pkg/file/folder_rename_detect.go +++ b/pkg/file/folder_rename_detect.go @@ -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 diff --git a/pkg/file/handler.go b/pkg/file/handler.go index 10616eefa..b4056f195 100644 --- a/pkg/file/handler.go +++ b/pkg/file/handler.go @@ -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 diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 0f6ffac11..d20fffdb1 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -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) diff --git a/pkg/file/stashignore.go b/pkg/file/stashignore.go index 160b5c224..681ccf795 100644 --- a/pkg/file/stashignore.go +++ b/pkg/file/stashignore.go @@ -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) diff --git a/pkg/file/stashignore_test.go b/pkg/file/stashignore_test.go index 5297f544b..41668b51b 100644 --- a/pkg/file/stashignore_test.go +++ b/pkg/file/stashignore_test.go @@ -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() { diff --git a/pkg/match/scraped.go b/pkg/match/scraped.go index d3039f4c6..a6683ff52 100644 --- a/pkg/match/scraped.go +++ b/pkg/match/scraped.go @@ -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 { diff --git a/pkg/models/date.go b/pkg/models/date.go index dbd5c4ec6..912361507 100644 --- a/pkg/models/date.go +++ b/pkg/models/date.go @@ -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 +} diff --git a/pkg/models/date_test.go b/pkg/models/date_test.go index b6cca9ee1..3b2962e28 100644 --- a/pkg/models/date_test.go +++ b/pkg/models/date_test.go @@ -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) + } + }) + } +} diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index b738fbfac..1a8acd5f3 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -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"` diff --git a/pkg/models/mocks/FileReaderWriter.go b/pkg/models/mocks/FileReaderWriter.go index 97a0136e6..4b370459e 100644 --- a/pkg/models/mocks/FileReaderWriter.go +++ b/pkg/models/mocks/FileReaderWriter.go @@ -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) } diff --git a/pkg/models/mocks/FolderReaderWriter.go b/pkg/models/mocks/FolderReaderWriter.go index 5d4d95027..bcca0acd1 100644 --- a/pkg/models/mocks/FolderReaderWriter.go +++ b/pkg/models/mocks/FolderReaderWriter.go @@ -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) } diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index a30eafa0a..7bc3b3174 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -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 diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 1367003cb..d20fbd589 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -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, diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index 09d8fbb32..1956d8a0b 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -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()}), diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 8de5d94f4..606b87f9f 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -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"` diff --git a/pkg/models/repository_file.go b/pkg/models/repository_file.go index c851ce08c..e1ac0b213 100644 --- a/pkg/models/repository_file.go +++ b/pkg/models/repository_file.go @@ -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) diff --git a/pkg/models/repository_folder.go b/pkg/models/repository_folder.go index 539d51cb9..67e3b141e 100644 --- a/pkg/models/repository_folder.go +++ b/pkg/models/repository_folder.go @@ -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) diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 691175b1f..d7807f651 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -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 { diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index 1a87bc2b1..2cf476321 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -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, diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 1df69521a..62b4d87d0 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -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) } diff --git a/pkg/performer/import_test.go b/pkg/performer/import_test.go index ca28c1990..0d5f80d01 100644 --- a/pkg/performer/import_test.go +++ b/pkg/performer/import_test.go @@ -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", } diff --git a/pkg/scraper/mapped_result.go b/pkg/scraper/mapped_result.go index 1260f3082..64cc97ec7 100644 --- a/pkg/scraper/mapped_result.go +++ b/pkg/scraper/mapped_result.go @@ -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"), diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index 4684a6683..e05240453 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -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"` diff --git a/pkg/scraper/post_processing_test.go b/pkg/scraper/post_processing_test.go new file mode 100644 index 000000000..2eb9385e1 --- /dev/null +++ b/pkg/scraper/post_processing_test.go @@ -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) + } + }) + } +} diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index 8a4d4de7d..4b8f7e022 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -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 diff --git a/pkg/scraper/query_url.go b/pkg/scraper/query_url.go index 2cd9f683e..7fe874947 100644 --- a/pkg/scraper/query_url.go +++ b/pkg/scraper/query_url.go @@ -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 } diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 8541f29db..d09096c6d 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -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)) } diff --git a/pkg/sqlite/file_filter.go b/pkg/sqlite/file_filter.go index 157efb1d8..29946a8ce 100644 --- a/pkg/sqlite/file_filter.go +++ b/pkg/sqlite/file_filter.go @@ -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) } } } diff --git a/pkg/sqlite/file_filter_test.go b/pkg/sqlite/file_filter_test.go index 50eed0129..648e502f7 100644 --- a/pkg/sqlite/file_filter_test.go +++ b/pkg/sqlite/file_filter_test.go @@ -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 { diff --git a/pkg/sqlite/file_test.go b/pkg/sqlite/file_test.go index bd971e3a5..b84ab7345 100644 --- a/pkg/sqlite/file_test.go +++ b/pkg/sqlite/file_test.go @@ -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, diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index 549b40d31..83308d39a 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -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)) } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index b92a1c073..e0ac576d8 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -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) } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 3bad40b3b..85337c911 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -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{ diff --git a/pkg/sqlite/migrations/78_postmigrate.go b/pkg/sqlite/migrations/78_postmigrate.go index 15d040457..34dbe6eb3 100644 --- a/pkg/sqlite/migrations/78_postmigrate.go +++ b/pkg/sqlite/migrations/78_postmigrate.go @@ -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 } diff --git a/pkg/sqlite/migrations/85_performer_career_dates.up.sql b/pkg/sqlite/migrations/85_performer_career_dates.up.sql new file mode 100644 index 000000000..1ce1cc97e --- /dev/null +++ b/pkg/sqlite/migrations/85_performer_career_dates.up.sql @@ -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; \ No newline at end of file diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 298a681fd..aacd9172f 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -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": diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index fdcc283ab..4336e998c 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -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, } } diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 46a5febee..ebe1b9eab 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -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 diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index d386175c7..67bf227a2 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -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{ diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index bbbb30baa..a922f10f9 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -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 diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 70d86ab5e..87376c2c1 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -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: diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index a866a94ab..87f905935 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -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": diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 750836516..f6a542c91 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -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": diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index acb2202dc..bc9a6ce89 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -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 + } } ` diff --git a/pkg/stashbox/performer.go b/pkg/stashbox/performer.go index 231b936d6..589fd29b6 100644 --- a/pkg/stashbox/performer.go +++ b/pkg/stashbox/performer.go @@ -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 { diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go index 452dd9928..45bcf96c4 100644 --- a/pkg/stashbox/tag.go +++ b/pkg/stashbox/tag.go @@ -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 } diff --git a/pkg/utils/date.go b/pkg/utils/date.go index 4b805862a..de5566e4d 100644 --- a/pkg/utils/date.go +++ b/pkg/utils/date.go @@ -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) - } -} diff --git a/pkg/utils/date_test.go b/pkg/utils/date_test.go index a9e174094..ae077c21e 100644 --- a/pkg/utils/date_test.go +++ b/pkg/utils/date_test.go @@ -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) - }) - } -} diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 18a8b4652..5780fec1d 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -162,6 +162,11 @@ fragment ScrapedSceneTagData on ScrapedTag { name description alias_list + parent { + stored_id + name + description + } remote_site_id } diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index 7e4207dce..df7517f7d 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -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", diff --git a/ui/v2.5/src/components/Changelog/styles.scss b/ui/v2.5/src/components/Changelog/styles.scss index 07c88f698..9d79e7d3b 100644 --- a/ui/v2.5/src/components/Changelog/styles.scss +++ b/ui/v2.5/src/components/Changelog/styles.scss @@ -20,11 +20,6 @@ padding: 0; } - ul { - list-style-type: none; - padding-left: 0.5rem; - } - &-version { &-body { padding: 1rem 2rem; diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx index 9ff7e00f2..cec44abf1 100644 --- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx @@ -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 = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); - const [organized, setOrganized] = useState(); + + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((gallery) => { + return gallery.id; + }), + }); + + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [sceneIds, setSceneIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const unsetDisabled = props.selected.length < 2; + + const [dateError, setDateError] = useState(); const [updateGalleries] = useBulkGalleryUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + 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) { + 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 = ( 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 ( - { - 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 ( = ( 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 = ( isRunning={isUpdating} >
- - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + - - - - - {renderMultiSelect("performers", performerIds)} - + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + - - - - - {renderMultiSelect("tags", tagIds)} - + + + setUpdateField({ photographer: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setSceneIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setSceneIds((c) => ({ ...c, mode: newMode })); + }} + ids={sceneIds.ids ?? []} + existingIds={aggregateState.sceneIds} + mode={sceneIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} />
diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx index 6fbb12f15..e0c115f34 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx @@ -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 = 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, diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index 174e507a8..c555116b5 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -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 = 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, diff --git a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx index ef3171de2..99c482aba 100644 --- a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx +++ b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx @@ -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 = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [director, setDirector] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((group) => { + return group.id; + }), + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); const [containingGroupsMode, setGroupMode] = React.useState(GQL.BulkUpdateIdMode.Add); const [containingGroups, setGroups] = useState(); - const [existingContainingGroups, setExistingContainingGroups] = - useState(); - const [updateGroups] = useBulkGroupUpdate(getGroupInput()); + const unsetDisabled = props.selected.length < 2; + const [updateGroups] = useBulkGroupUpdate(); + + const [dateError, setDateError] = useState(); + + // 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 = {}; + 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) { + 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 = ( 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 = ( 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 ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -204,74 +195,90 @@ export const EditGroupsDialog: React.FC = ( isRunning={isUpdating} >
- - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - - - - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + + + + + setUpdateField({ director: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + setGroups(v)} onSetMode={(newMode) => setGroupMode(newMode)} - existingValue={existingContainingGroups ?? []} + existingValue={aggregateState.containingGroups ?? []} value={containingGroups ?? []} mode={containingGroupsMode} menuPortalTarget={document.body} /> - - - - - - setDirector(event.currentTarget.value)} - placeholder={intl.formatMessage({ id: "director" })} - /> - - - - - + + + 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} /> - + + + + + setUpdateField({ synopsis: newValue }) + } + unsetDisabled={unsetDisabled} + as="textarea" + /> +
); diff --git a/ui/v2.5/src/components/Images/EditImagesDialog.tsx b/ui/v2.5/src/components/Images/EditImagesDialog.tsx index 275ff1556..a90ef922e 100644 --- a/ui/v2.5/src/components/Images/EditImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/EditImagesDialog.tsx @@ -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 = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((image) => { + return image.id; + }), + }); - const [galleryMode, setGalleryMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [galleryIds, setGalleryIds] = useState(); - const [existingGalleryIds, setExistingGalleryIds] = useState(); + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [galleryIds, setGalleryIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); - const [organized, setOrganized] = useState(); + const unsetDisabled = props.selected.length < 2; + + const [dateError, setDateError] = useState(); const [updateImages] = useBulkImageUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + 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) { + 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 = ( 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 = ( 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 ( = ( 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 = ( isRunning={isUpdating} >
- - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - - - - - - - + + setUpdateField({ rating100: value ?? undefined }) + } disabled={isUpdating} - onUpdate={(itemIDs) => setPerformerIds(itemIDs)} - onSetMode={(newMode) => setPerformerMode(newMode)} - existingIds={existingPerformerIds ?? []} - ids={performerIds ?? []} - mode={performerMode} + /> + + + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + + + + + setUpdateField({ photographer: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} menuPortalTarget={document.body} /> - + - - - - + 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} /> - + - - - - + 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} /> - + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} />
diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 35c367a8a..00b23b0aa 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -751,7 +751,7 @@ export const FilteredImageList = PatchComponent( currentPage={filter.currentPage} itemsPerPage={filter.itemsPerPage} totalItems={totalCount} - onChangePage={(page) => setFilter(filter.changePage(page))} + onChangePage={setPage} /> setFilter(filter.changePage(page))} + onChangePage={setPage} onSelectChange={onSelectChange} pageCount={pageCount} selectedIds={selectedIds} diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 89c32222f..da52ea765 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -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( 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; } diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index d60118d4b..06ae2834a 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -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 = ( const [aggregateState, setAggregateState] = useState({}); // height and weight needs conversion to/from number - const [height, setHeight] = useState(); - const [weight, setWeight] = useState(); - const [penis_length, setPenisLength] = useState(); + const [height, setHeight] = useState(); + const [weight, setWeight] = useState(); + const [penis_length, setPenisLength] = useState(); const [updateInput, setUpdateInput] = useState( {} ); const genderOptions = [""].concat(genderStrings); const circumcisedOptions = [""].concat(circumcisedStrings); + const unsetDisabled = props.selected.length < 2; + const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); + const [birthdateError, setBirthdateError] = useState(); + const [deathDateError, setDeathDateError] = useState(); + const [careerStartError, setCareerStartError] = useState< + string | undefined + >(); + const [careerEndError, setCareerEndError] = useState(); + + 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 = ( ); 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 = ( setUpdateInput(updateState); }, [props.selected]); - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - /> - - ); - } - function render() { // sfw class needs to be set because it is outside body @@ -235,13 +242,18 @@ export const EditPerformersDialog: React.FC = ( 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 = ( }} isRunning={isUpdating} > - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - +
+ @@ -261,9 +270,8 @@ export const EditPerformersDialog: React.FC = ( } disabled={isUpdating} /> - - - + + setUpdateField({ favorite: checked })} @@ -272,10 +280,7 @@ export const EditPerformersDialog: React.FC = ( /> - - - - + = ( ))} - + - {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 }) - )} + + + setUpdateField({ disambiguation: newValue }) + } + unsetDisabled={unsetDisabled} + /> + - - - - + + + setUpdateField({ birthdate: newValue }) + } + unsetDisabled={unsetDisabled} + error={birthdateError} + /> + + + + setUpdateField({ death_date: newValue }) + } + unsetDisabled={unsetDisabled} + error={deathDateError} + /> + + setUpdateField({ country: v })} showFlag /> - + - {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) - )} + + + setUpdateField({ ethnicity: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ hair_color: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ eye_color: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setHeight(newValue)} + unsetDisabled={unsetDisabled} + /> + + + setWeight(newValue)} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ measurements: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setPenisLength(newValue)} + unsetDisabled={unsetDisabled} + /> + - - - - + = ( ))} - + - {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 }) - )} + + + setUpdateField({ fake_tits: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setUpdateField({ tattoos: newValue })} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ piercings: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ career_start: newValue }) + } + unsetDisabled={unsetDisabled} + error={careerStartError} + /> + + + + setUpdateField({ career_end: newValue }) + } + unsetDisabled={unsetDisabled} + error={careerEndError} + /> + - - - - + 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} /> - + = ({ 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 = ({ 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 = ({ {renderInputField("tattoos", "textarea")} {renderInputField("piercings", "textarea")} - {renderInputField("career_start", "number")} - {renderInputField("career_end", "number")} + {renderDateField("career_start")} + {renderDateField("career_end")} {renderURLListField("urls", onScrapePerformerURL, urlScrapable)} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index d5146592b..d1005b68c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -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 = ( 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 = ( const [fakeTits, setFakeTits] = useState>( new ScrapeResult(props.performer.fake_tits, props.scraped.fake_tits) ); - const [careerStart, setCareerStart] = useState>( - new ScrapeResult( + const [careerStart, setCareerStart] = useState>( + new ScrapeResult( props.performer.career_start, props.scraped.career_start ) ); - const [careerEnd, setCareerEnd] = useState>( - new ScrapeResult( + const [careerEnd, setCareerEnd] = useState>( + new ScrapeResult( props.performer.career_end, props.scraped.career_end ) @@ -502,13 +501,13 @@ export const PerformerScrapeDialog: React.FC = ( result={fakeTits} onChange={(value) => setFakeTits(value)} /> - setCareerStart(value)} /> - { }; 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 ""; diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx index efa51f1db..0d42dd6ed 100644 --- a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -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 = ({ ) ); + const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); + const [image, setImage] = useState>( new ScrapeResult(dest.image_path) ); @@ -166,6 +170,10 @@ const PerformerMergeDetails: React.FC = ({ 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 = ({ ); 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 = ({ !dest.details ) ); + setTags( + new ObjectListScrapeResult( + 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 = ({ result={details} onChange={(value) => setDetails(value)} /> + + } + newField={} + onChange={(value) => setStashIDs(value)} + alwaysShow={ + !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length + } + /> = ({ : 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 = ({ 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( diff --git a/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx b/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx index bb1d8067b..1856543f9 100644 --- a/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx @@ -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 = ( mode: GQL.BulkUpdateIdMode.Add, }); + const unsetDisabled = props.selected.length < 2; + const [updateSceneMarkers] = useBulkSceneMarkerUpdate(); // Network state @@ -115,27 +117,6 @@ export const EditSceneMarkersDialog: React.FC = ( setIsUpdating(false); } - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void, - area: boolean = false - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - as={area ? "textarea" : undefined} - /> - - ); - } - function render() { return ( = ( 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 = ( isRunning={isUpdating} > - {renderTextField("title", updateInput.title, (newValue) => - setUpdateField({ title: newValue }) - )} + + setUpdateField({ title: newValue })} + unsetDisabled={unsetDisabled} + /> + - - - - + setUpdateField({ primary_tag_id: t[0]?.id })} ids={ updateInput.primary_tag_id ? [updateInput.primary_tag_id] : [] } /> - + - - - - + 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} /> - + ); diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index 7b69cf655..17466bfc9 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -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 = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); - const [groupMode, setGroupMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [groupIds, setGroupIds] = useState(); - const [existingGroupIds, setExistingGroupIds] = useState(); - const [organized, setOrganized] = useState(); - const [updateScenes] = useBulkSceneUpdate(getSceneInput()); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((scene) => { + return scene.id; + }), + }); + + const [dateError, setDateError] = useState(); + + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [groupIds, setGroupIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const unsetDisabled = props.selected.length < 2; + + const [updateScenes] = useBulkSceneUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + 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) { + 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 = ( 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 = ( 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 ( - { - 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 ( = ( 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 = ( isRunning={isUpdating} >
- - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + - - - - - {renderMultiSelect("performers", performerIds)} - + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + - - - - - {renderMultiSelect("tags", tagIds)} - + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + - - - - - {renderMultiSelect("groups", groupIds)} - + + + setUpdateField({ director: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setGroupIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setGroupIds((c) => ({ ...c, mode: newMode })); + }} + ids={groupIds.ids ?? []} + existingIds={aggregateState.groupIds} + mode={groupIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} />
diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index b7c263168..0a80880f1 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -352,6 +352,39 @@ const SceneCardOverlays = PatchComponent( } ); +interface ISceneSpecsOverlay { + scene: GQL.SlimSceneDataFragment; +} + +export const SceneSpecsOverlay: React.FC = PatchComponent( + "SceneCard.SceneSpecs", + ({ scene }) => { + const file = scene.files?.[0]; + if (!file) return null; + return ( +
+ + + + {file.width && file.height ? ( + + {TextUtils.resolution(file.width, file.height)} + + ) : ( + "" + )} + {file.duration > 0 ? ( + + {TextUtils.secondsToTimestamp(file.duration)} + + ) : ( + "" + )} +
+ ); + } +); + const SceneCardImage = PatchComponent( "SceneCard.Image", (props: ISceneCardProps) => { @@ -364,35 +397,6 @@ const SceneCardImage = PatchComponent( [props.scene] ); - function maybeRenderSceneSpecsOverlay() { - return ( -
- {file?.size !== undefined ? ( - - - - ) : ( - "" - )} - {file?.width && file?.height ? ( - - {" "} - {TextUtils.resolution(file?.width, file?.height)} - - ) : ( - "" - )} - {(file?.duration ?? 0) >= 1 ? ( - - {TextUtils.secondsToTimestamp(file?.duration ?? 0)} - - ) : ( - "" - )} -
- ); - } - function maybeRenderInteractiveSpeedOverlay() { return (
@@ -432,7 +436,7 @@ const SceneCardImage = PatchComponent( disabled={props.selecting} /> - {maybeRenderSceneSpecsOverlay()} + {maybeRenderInteractiveSpeedOverlay()} ); diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 89d445002..29ae93143 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -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 = ({ values }) => { - return v.stash_id)} />; -}; +import { StashIDsField } from "../Shared/StashID"; type MergeOptions = { values: GQL.SceneUpdateInput; @@ -132,12 +125,6 @@ const SceneMergeDetails: React.FC = ({ return ret; } - function uniqIDStoredIDs(objs: T[]) { - return objs.filter((o, i) => { - return objs.findIndex((oo) => oo.stored_id === o.stored_id) === i; - }); - } - const [performers, setPerformers] = useState< ObjectListScrapeResult >( @@ -604,6 +591,9 @@ const SceneMergeDetails: React.FC = ({ } newField={} onChange={(value) => setStashIDs(value)} + alwaysShow={ + !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length + } /> 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; } diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 0ebe3f736..85af31a4a 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -200,6 +200,7 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( onChange={(v) => saveInterface({ language: v })} > + diff --git a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx index a6bda7c09..0490a0f9d 100644 --- a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx @@ -145,6 +145,13 @@ const CleanOptions: React.FC = ({ return ( <> + setOptions({ ignoreZipFileContents: v })} + /> void; + onRefreshChange?: (refresh: boolean) => void; + batchAddParents: boolean; + setBatchAddParents: (addParents: boolean) => void; + close: () => void; + localePrefix: string; + entityName: string; + countVariableName: string; +} + +export const BatchUpdateModal: React.FC = ({ + 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 ( + onBatchUpdate(queryAll, refresh), + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + +
+ +
+
+ } + checked={!queryAll} + onChange={() => setQueryAll(false)} + /> + setQueryAll(true)} + /> +
+ + +
+ +
+
+ setRefresh(false)} + /> + + + + setRefresh(true)} + /> + + + +
+
+ setBatchAddParents(!batchAddParents)} + /> +
+ + + +
+ ); +}; + +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 = ({ + isIdle, + onBatchAdd, + batchAddParents, + setBatchAddParents, + close, + localePrefix, + entityName, +}) => { + const intl = useIntl(); + + const inputRef = useRef(null); + + return ( + { + if (inputRef.current) { + onBatchAdd(inputRef.current.value); + } else { + close(); + } + }, + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + + + +
+ setBatchAddParents(!batchAddParents)} + /> +
+
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/BulkUpdate.tsx b/ui/v2.5/src/components/Shared/BulkUpdate.tsx new file mode 100644 index 000000000..8a1b7c884 --- /dev/null +++ b/ui/v2.5/src/components/Shared/BulkUpdate.tsx @@ -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 { + valueChanged: (value: string | null | undefined) => void; + value: string | null | undefined; + unsetDisabled?: boolean; + as?: React.ElementType; +} + +export const BulkUpdateTextInput: React.FC = ({ + 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 ( + + valueChanged(event.currentTarget.value)} + /> + + {!unsetDisabled ? ( + + ) : undefined} + + + ); +}; + +export const BulkUpdateFormGroup: React.FC<{ + name: string; + messageId?: string; + inline?: boolean; +}> = ({ name, messageId = name, inline = true, children }) => { + if (inline) { + return ( + + {FormUtils.renderLabel({ + title: , + })} + {children} + + ); + } + + return ( + + + + + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx deleted file mode 100644 index cf78798e1..000000000 --- a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx +++ /dev/null @@ -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 = ({ - valueChanged, - unsetDisabled, - ...props -}) => { - const intl = useIntl(); - - const unsetClassName = props.value === undefined ? "unset" : ""; - - return ( - - ` - : undefined - } - onChange={(event) => valueChanged(event.currentTarget.value)} - /> - {!unsetDisabled ? ( - - ) : undefined} - - ); -}; diff --git a/ui/v2.5/src/components/Shared/DateInput.tsx b/ui/v2.5/src/components/Shared/DateInput.tsx index 15a0f1123..4bb39ac39 100644 --- a/ui/v2.5/src/components/Shared/DateInput.tsx +++ b/ui/v2.5/src/components/Shared/DateInput.tsx @@ -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 = (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 = (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 ( -
- - props.onValueChange(e.currentTarget.value)} - placeholder={ - !props.disabled - ? props.placeholder - ? `${props.placeholder} (${placeholderText})` - : placeholderText - : undefined - } - isInvalid={!!props.error} - /> - {maybeRenderButton()} - - {props.error} - - -
+ + props.onValueChange(e.currentTarget.value)} + placeholder={ + !props.disabled + ? props.placeholderOverride ?? placeholderText + : undefined + } + isInvalid={!!props.error} + /> + + {props.appendBefore} + {maybeRenderButton()} + {props.appendAfter} + + + {props.error} + + ); }; export const DateInput = PatchComponent("DateInput", _DateInput); + +interface IBulkUpdateDateInputProps + extends Omit { + value: string | null | undefined; + valueChanged: (value: string | null | undefined) => void; + unsetDisabled?: boolean; + as?: React.ElementType; + error?: string; +} + +export const BulkUpdateDateInput: React.FC = ({ + valueChanged, + unsetDisabled, + ...props +}) => { + const intl = useIntl(); + + const unset = props.value === undefined; + + const unsetButton = !unsetDisabled ? ( + + ) : undefined; + + const clearButton = + props.value !== null ? ( + + ) : 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 ( + valueChanged(outValue(v))} + groupClassName="bulk-update-date-input" + className="date-input text-input" + appendBefore={clearButton} + appendAfter={unsetButton} + /> + ); +}; diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx index 732b1cffb..e1c117aac 100644 --- a/ui/v2.5/src/components/Shared/FilterSelect.tsx +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -58,7 +58,6 @@ const SelectComponent = ( ) => { const { selectedOptions, - isLoading, isDisabled = false, creatable = false, components, @@ -101,10 +100,7 @@ const SelectComponent = ( }; return creatable ? ( - + ) : ( ); diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index 6be85b8b3..8f16bd716 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -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 = (props) => { menuPortalTarget={props.menuPortalTarget} /> ); + case "scenes": + return ( + + ); default: return ( extends IScrapedFieldProps { newField: React.ReactNode; onChange: (value: ScrapeResult) => void; newValues?: React.ReactNode; + alwaysShow?: boolean; } export const ScrapeDialogRow = (props: IScrapedRowProps) => { @@ -51,7 +52,7 @@ export const ScrapeDialogRow = (props: IScrapedRowProps) => { props.onChange(ret); } - if (!props.result.scraped && !props.newValues) { + if (!props.result.scraped && !props.newValues && !props.alwaysShow) { return <>; } diff --git a/ui/v2.5/src/components/Shared/StashID.tsx b/ui/v2.5/src/components/Shared/StashID.tsx index 847dd7ab2..be9ee0fba 100644 --- a/ui/v2.5/src/components/Shared/StashID.tsx +++ b/ui/v2.5/src/components/Shared/StashID.tsx @@ -31,3 +31,21 @@ export const StashIDPill: React.FC<{ ); }; + +interface IStashIDsField { + values: StashId[]; +} + +export const StashIDsField: React.FC = ({ values }) => { + if (!values.length) return null; + + return ( +
    + {values.map((v) => ( +
  • + +
  • + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f2881fc55..709712231 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -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; +} diff --git a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx index 1c34dfc36..72fcb3f71 100644 --- a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx +++ b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx @@ -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 = ( mode: GQL.BulkUpdateIdMode.Add, }); + const unsetDisabled = props.selected.length < 2; + const [updateStudios] = useBulkStudioUpdate(); // Network state @@ -126,27 +127,6 @@ export const EditStudiosDialog: React.FC = ( setIsUpdating(false); } - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void, - area: boolean = false - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - as={area ? "textarea" : undefined} - /> - - ); - } - function render() { return ( = ( 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 = ( }} isRunning={isUpdating} > - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "parent_studio" }), - })} - +
+ setUpdateField({ @@ -183,13 +164,8 @@ export const EditStudiosDialog: React.FC = ( isDisabled={isUpdating} menuPortalTarget={document.body} /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - + + @@ -197,9 +173,8 @@ export const EditStudiosDialog: React.FC = ( } disabled={isUpdating} /> - - - + + setUpdateField({ favorite: checked })} @@ -208,30 +183,31 @@ export const EditStudiosDialog: React.FC = ( /> - - - - + 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} /> - + - {renderTextField( - "details", - updateInput.details, - (newValue) => setUpdateField({ details: newValue }), - true - )} + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + = ({ function maybeRenderStashBoxLink() { const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; - if (!base) return; + if (!base || !performer.remote_site_id) return; return ( -
- - - - -
+ ); } diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index af9afcefb..646dbf4c3 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -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"]; diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx index 5446257e5..5ad895fc2 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx @@ -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> = ({ vttPath={scene.paths.vtt ?? undefined} onScrubberClick={onScrubberClick} /> + {maybeRenderSpriteIcon()}
diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index 64bb99b72..adc58cc04 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; @@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { ModalComponent } from "src/components/Shared/Modal"; import { stashBoxStudioQuery, useJobsSubscribe, @@ -25,11 +24,15 @@ import { ITaggerConfig, STUDIO_FIELDS } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; import { apolloError } from "src/utils"; -import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; +import { faTags } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeStudioStashIDs } from "../utils"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { useTaggerConfig } from "../config"; +import { + BatchUpdateModal, + BatchAddModal, +} from "src/components/Shared/BatchModals"; type JobFragment = Pick< GQL.Job, @@ -38,232 +41,6 @@ type JobFragment = Pick< const CLASSNAME = "StudioTagger"; -interface IStudioBatchUpdateModal { - studios: GQL.StudioDataFragment[]; - isIdle: boolean; - selectedEndpoint: { endpoint: string; index: number }; - onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; - batchAddParents: boolean; - setBatchAddParents: (addParents: boolean) => void; - close: () => void; -} - -const StudioBatchUpdateModal: React.FC = ({ - studios, - isIdle, - selectedEndpoint, - onBatchUpdate, - batchAddParents, - setBatchAddParents, - close, -}) => { - const intl = useIntl(); - - const [queryAll, setQueryAll] = useState(false); - - const [refresh, setRefresh] = useState(false); - const { data: allStudios } = GQL.useFindStudiosQuery({ - variables: { - studio_filter: { - stash_id_endpoint: { - endpoint: selectedEndpoint.endpoint, - modifier: refresh - ? GQL.CriterionModifier.NotNull - : GQL.CriterionModifier.IsNull, - }, - }, - filter: { - per_page: 0, - }, - }, - }); - - const studioCount = useMemo(() => { - // get all stash ids for the selected endpoint - const filteredStashIDs = studios.map((p) => - p.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) - ); - - return queryAll - ? allStudios?.findStudios.count - : filteredStashIDs.filter((s) => - // if refresh, then we filter out the studios without a stash id - // otherwise, we want untagged studios, filtering out those with a stash id - refresh ? s.length > 0 : s.length === 0 - ).length; - }, [queryAll, refresh, studios, allStudios, selectedEndpoint.endpoint]); - - return ( - onBatchUpdate(queryAll, refresh), - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - -
- -
-
- } - checked={!queryAll} - onChange={() => setQueryAll(false)} - /> - setQueryAll(true)} - /> -
- - -
- -
-
- setRefresh(false)} - /> - - - - setRefresh(true)} - /> - - - -
- setBatchAddParents(!batchAddParents)} - /> -
-
- - - -
- ); -}; - -interface IStudioBatchAddModal { - isIdle: boolean; - onBatchAdd: (input: string) => void; - batchAddParents: boolean; - setBatchAddParents: (addParents: boolean) => void; - close: () => void; -} - -const StudioBatchAddModal: React.FC = ({ - isIdle, - onBatchAdd, - batchAddParents, - setBatchAddParents, - close, -}) => { - const intl = useIntl(); - - const studioInput = useRef(null); - - return ( - { - if (studioInput.current) { - onBatchAdd(studioInput.current.value); - } else { - close(); - } - }, - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - - - -
- setBatchAddParents(!batchAddParents)} - /> -
-
- ); -}; - interface IStudioTaggerListProps { studios: GQL.StudioDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; @@ -305,6 +82,24 @@ const StudioTaggerList: React.FC = ({ config.createParentStudios || false ); + const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); + const { data: allStudios } = GQL.useFindStudiosQuery({ + skip: !showBatchUpdate, + variables: { + studio_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: batchUpdateRefresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); + const [error, setError] = useState< Record >({}); @@ -630,24 +425,31 @@ const StudioTaggerList: React.FC = ({ return ( {showBatchUpdate && ( - setShowBatchUpdate(false)} isIdle={isIdle} selectedEndpoint={selectedEndpoint} - studios={studios} + entities={studios} + allCount={allStudios?.findStudios.count} onBatchUpdate={handleBatchUpdate} + onRefreshChange={setBatchUpdateRefresh} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} + localePrefix="studio_tagger" + entityName="studio" + countVariableName="studio_count" /> )} {showBatchAdd && ( - setShowBatchAdd(false)} isIdle={isIdle} onBatchAdd={handleBatchAdd} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} + localePrefix="studio_tagger" + entityName="studio" /> )}
diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 8861d0043..1c05e574f 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -8,6 +8,11 @@ .scene-card { position: relative; + + .scene-specs-overlay { + bottom: 5px; + right: 5px; + } } .scene-card-preview { @@ -282,7 +287,8 @@ } } -.StudioTagger { +.StudioTagger, +.TagTagger { display: flex; flex-wrap: wrap; justify-content: center; @@ -337,7 +343,8 @@ vertical-align: bottom; } - &-studio-search { + &-studio-search, + &-tag-search { display: flex; flex-wrap: wrap; diff --git a/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx index cd6abca02..55b86c931 100644 --- a/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx @@ -7,6 +7,8 @@ import TagModal from "./TagModal"; import { faTags } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; import { mergeTagStashIDs } from "../utils"; +import { useTagCreate } from "src/core/StashService"; +import { apolloError } from "src/utils"; interface IStashSearchResultProps { tag: GQL.TagListDataFragment; @@ -34,13 +36,49 @@ const StashSearchResult: React.FC = ({ {} ); + const [createTag] = useTagCreate(); const updateTag = useUpdateTag(); - const handleSave = async (input: GQL.TagCreateInput) => { + function handleSaveError(name: string, message: string) { + setError({ + message: intl.formatMessage( + { id: "tag_tagger.failed_to_save_tag" }, + { tag: name } + ), + details: + message === "UNIQUE constraint failed: tags.name" + ? intl.formatMessage({ + id: "tag_tagger.name_already_exists", + }) + : message, + }); + } + + const handleSave = async ( + input: GQL.TagCreateInput, + parentInput?: GQL.TagCreateInput + ) => { setError({}); setModalTag(undefined); - setSaveState("Saving tag"); + if (parentInput) { + setSaveState("Saving parent tag"); + + try { + const parentRes = await createTag({ + variables: { input: parentInput }, + }); + input.parent_ids = [parentRes.data?.tagCreate?.id].filter( + Boolean + ) as string[]; + } catch (e) { + handleSaveError(parentInput.name, apolloError(e)); + setSaveState(""); + return; + } + } + + setSaveState("Saving tag"); const updateData: GQL.TagUpdateInput = { ...input, id: tag.id, @@ -54,18 +92,7 @@ const StashSearchResult: React.FC = ({ const res = await updateTag(updateData); if (!res?.data?.tagUpdate) { - setError({ - message: intl.formatMessage( - { id: "tag_tagger.failed_to_save_tag" }, - { tag: input.name ?? tag.name } - ), - details: - res?.errors?.[0]?.message === "UNIQUE constraint failed: tags.name" - ? intl.formatMessage({ - id: "tag_tagger.name_already_exists", - }) - : res?.errors?.[0]?.message ?? "", - }); + handleSaveError(input.name ?? tag.name, res?.errors?.[0]?.message ?? ""); } else { onTagTagged(tag); } @@ -74,7 +101,7 @@ const StashSearchResult: React.FC = ({ const tags = stashboxTags.map((p) => ( + {isSelectable && ( + + )} : @@ -85,15 +124,82 @@ const TagModal: React.FC = ({ ); } + function maybeRenderParentField( + id: string, + text: string | null | undefined, + isSelectable: boolean = true + ) { + if (!text) return; + + return ( +
+
+ {isSelectable && ( + + )} + + : + +
+ +
+ ); + } + + function maybeRenderParentTagDetails() { + if (!createParentTag || !tag.parent) { + return; + } + + return ( +
+ {maybeRenderParentField("name", tag.parent.name, false)} + {maybeRenderParentField("description", tag.parent.description)} +
+ ); + } + + function maybeRenderParentTag() { + // No parent tag, or parent already exists locally + if (!tag.parent || tag.parent.stored_id || !sendParentTag) { + return; + } + + return ( +
+
+ setCreateParentTag(!createParentTag)} + /> +
+ {maybeRenderParentTagDetails()} +
+ ); + } + function handleSave() { if (!tag.name) { throw new Error("tag name must be set"); } + const parentId = tag.parent?.stored_id ?? existingParentId; + const tagData: GQL.TagCreateInput = { name: tag.name, description: tag.description ?? undefined, aliases: tag.alias_list?.filter((a) => a) ?? undefined, + parent_ids: parentId ? [parentId] : undefined, }; // stashid handling code @@ -111,7 +217,27 @@ const TagModal: React.FC = ({ // handle exclusions excludeFields(tagData, excluded); - onSave(tagData); + let parentData: GQL.TagCreateInput | undefined = undefined; + + // Categories don't have stash IDs, so we only create new parent tags + if ( + createParentTag && + sendParentTag && + tag.parent && + !tag.parent.stored_id + ) { + parentData = { + name: tag.parent.name, + description: tag.parent.description ?? undefined, + }; + + // handle exclusions + // Can't exclude parent tag name when creating a new one + parentExcluded.name = false; + excludeFields(parentData, parentExcluded); + } + + onSave(tagData, parentData); } return ( @@ -133,10 +259,12 @@ const TagModal: React.FC = ({ {maybeRenderField("name", tag.name)} {maybeRenderField("description", tag.description)} {maybeRenderField("aliases", tag.alias_list?.join(", "))} + {maybeRenderField("parent_tags", tag.parent?.name, false)} {maybeRenderStashBoxLink()}
+ {maybeRenderParentTag()}
); }; diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx index 1113bdfd4..21891724c 100644 --- a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; @@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { ModalComponent } from "src/components/Shared/Modal"; import { stashBoxTagQuery, useJobsSubscribe, @@ -20,221 +19,33 @@ import StashSearchResult from "./StashSearchResult"; import TaggerConfig from "../TaggerConfig"; import { ITaggerConfig, TAG_FIELDS } from "../constants"; import { useUpdateTag } from "../queries"; -import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeTagStashIDs } from "../utils"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { useTaggerConfig } from "../config"; +import { + BatchUpdateModal, + BatchAddModal, +} from "src/components/Shared/BatchModals"; type JobFragment = Pick< GQL.Job, "id" | "status" | "subTasks" | "description" | "progress" >; -const CLASSNAME = "StudioTagger"; - -interface ITagBatchUpdateModal { - tags: GQL.TagListDataFragment[]; - isIdle: boolean; - selectedEndpoint: { endpoint: string; index: number }; - onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; - close: () => void; -} - -const TagBatchUpdateModal: React.FC = ({ - tags, - isIdle, - selectedEndpoint, - onBatchUpdate, - close, -}) => { - const intl = useIntl(); - - const [queryAll, setQueryAll] = useState(false); - - const [refresh, setRefresh] = useState(false); - const { data: allTags } = GQL.useFindTagsQuery({ - variables: { - tag_filter: { - stash_id_endpoint: { - endpoint: selectedEndpoint.endpoint, - modifier: refresh - ? GQL.CriterionModifier.NotNull - : GQL.CriterionModifier.IsNull, - }, - }, - filter: { - per_page: 0, - }, - }, - }); - - const tagCount = useMemo(() => { - const filteredStashIDs = tags.map((t) => - t.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) - ); - - return queryAll - ? allTags?.findTags.count - : filteredStashIDs.filter((s) => - refresh ? s.length > 0 : s.length === 0 - ).length; - }, [queryAll, refresh, tags, allTags, selectedEndpoint.endpoint]); - - return ( - onBatchUpdate(queryAll, refresh), - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - -
- -
-
- } - checked={!queryAll} - onChange={() => setQueryAll(false)} - /> - setQueryAll(true)} - /> -
- - -
- -
-
- setRefresh(false)} - /> - - - - setRefresh(true)} - /> - - - -
- - - -
- ); -}; - -interface ITagBatchAddModal { - isIdle: boolean; - onBatchAdd: (input: string) => void; - close: () => void; -} - -const TagBatchAddModal: React.FC = ({ - isIdle, - onBatchAdd, - close, -}) => { - const intl = useIntl(); - - const tagInput = useRef(null); - - return ( - { - if (tagInput.current) { - onBatchAdd(tagInput.current.value); - } else { - close(); - } - }, - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - - - - - ); -}; +const CLASSNAME = "TagTagger"; interface ITagTaggerListProps { tags: GQL.TagListDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; isIdle: boolean; config: ITaggerConfig; - onBatchAdd: (tagInput: string) => void; - onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void; + onBatchAdd: (tagInput: string, createParent: boolean) => void; + onBatchUpdate: ( + ids: string[] | undefined, + refresh: boolean, + createParent: boolean + ) => void; } const TagTaggerList: React.FC = ({ @@ -261,6 +72,27 @@ const TagTaggerList: React.FC = ({ const [showBatchAdd, setShowBatchAdd] = useState(false); const [showBatchUpdate, setShowBatchUpdate] = useState(false); + const [batchAddParents, setBatchAddParents] = useState( + config.createParentTags || false + ); + + const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); + const { data: allTags } = GQL.useFindTagsQuery({ + skip: !showBatchUpdate, + variables: { + tag_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: batchUpdateRefresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); const [error, setError] = useState< Record @@ -360,12 +192,16 @@ const TagTaggerList: React.FC = ({ }; async function handleBatchAdd(input: string) { - onBatchAdd(input); + onBatchAdd(input, batchAddParents); setShowBatchAdd(false); } const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { - onBatchUpdate(!queryAll ? tags.map((t) => t.id) : undefined, refresh); + onBatchUpdate( + !queryAll ? tags.map((t) => t.id) : undefined, + refresh, + batchAddParents + ); setShowBatchUpdate(false); }; @@ -451,7 +287,7 @@ const TagTaggerList: React.FC = ({ subContent = (
- + {link}