Merge branch 'stashapp:develop' into frames

This commit is contained in:
bob12224 2026-03-18 11:52:49 -07:00 committed by GitHub
commit 7f9fde313b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
159 changed files with 7198 additions and 4048 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -73,6 +73,7 @@ type ScrapedTag {
name: String!
description: String
alias_list: [String!]
parent: ScrapedTag
"Remote site ID, if applicable"
remote_site_id: String
}

View file

@ -31,6 +31,11 @@ fragment TagFragment on Tag {
id
description
aliases
category {
id
name
description
}
}
fragment MeasurementsFragment on Measurements {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,144 @@
package scraper
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
)
func TestPostScrapePerformerCareerLength(t *testing.T) {
ctx := context.Background()
const related = false
strPtr := func(s string) *string {
return &s
}
tests := []struct {
name string
input models.ScrapedPerformer
want models.ScrapedPerformer
}{
{
"start = 2000",
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
},
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerLength: strPtr("2000 -"),
},
},
{
"end = 2000",
models.ScrapedPerformer{
CareerEnd: strPtr("2000"),
},
models.ScrapedPerformer{
CareerEnd: strPtr("2000"),
CareerLength: strPtr("- 2000"),
},
},
{
"start = 2000, end = 2020",
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerEnd: strPtr("2020"),
},
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerEnd: strPtr("2020"),
CareerLength: strPtr("2000 - 2020"),
},
},
{
"length = 2000 -",
models.ScrapedPerformer{
CareerLength: strPtr("2000 -"),
},
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerLength: strPtr("2000 -"),
},
},
{
"length = - 2010",
models.ScrapedPerformer{
CareerLength: strPtr("- 2010"),
},
models.ScrapedPerformer{
CareerEnd: strPtr("2010"),
CareerLength: strPtr("- 2010"),
},
},
{
"length = 2000 - 2010",
models.ScrapedPerformer{
CareerLength: strPtr("2000 - 2010"),
},
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerEnd: strPtr("2010"),
CareerLength: strPtr("2000 - 2010"),
},
},
{
"invalid start",
models.ScrapedPerformer{
CareerStart: strPtr("two thousand"),
},
models.ScrapedPerformer{
CareerStart: strPtr("two thousand"),
},
},
{
"invalid end",
models.ScrapedPerformer{
CareerEnd: strPtr("two thousand"),
},
models.ScrapedPerformer{
CareerEnd: strPtr("two thousand"),
},
},
{
"invalid career length",
models.ScrapedPerformer{
CareerLength: strPtr("1234 - 4567 - 9224"),
},
models.ScrapedPerformer{
CareerLength: strPtr("1234 - 4567 - 9224"),
},
},
}
compareStrPtr := func(a, b *string) bool {
if a == b {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &postScraper{}
got, err := c.postScrapePerformer(ctx, tt.input, related)
if err != nil {
t.Fatalf("postScrapePerformer returned error: %v", err)
}
postScraped := got.(models.ScrapedPerformer)
if !compareStrPtr(postScraped.CareerStart, tt.want.CareerStart) {
t.Errorf("CareerStart = %v, want %v", postScraped.CareerStart, tt.want.CareerStart)
}
if !compareStrPtr(postScraped.CareerEnd, tt.want.CareerEnd) {
t.Errorf("CareerEnd = %v, want %v", postScraped.CareerEnd, tt.want.CareerEnd)
}
if !compareStrPtr(postScraped.CareerLength, tt.want.CareerLength) {
t.Errorf("CareerLength = %v, want %v", postScraped.CareerLength, tt.want.CareerLength)
}
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,112 @@
-- have to change the type of the career start/end columns so need to recreate the table
PRAGMA foreign_keys=OFF;
CREATE TABLE IF NOT EXISTS "performers_new" (
`id` integer not null primary key autoincrement,
`name` varchar(255) not null,
`disambiguation` varchar(255),
`gender` varchar(20),
`birthdate` date,
`birthdate_precision` TINYINT,
`ethnicity` varchar(255),
`country` varchar(255),
`eye_color` varchar(255),
`height` int,
`measurements` varchar(255),
`fake_tits` varchar(255),
`tattoos` varchar(255),
`piercings` varchar(255),
`favorite` boolean not null default '0',
`created_at` datetime not null,
`updated_at` datetime not null,
`details` text,
`death_date` date,
`death_date_precision` TINYINT,
`hair_color` varchar(255),
`weight` integer,
`rating` tinyint,
`ignore_auto_tag` boolean not null default '0',
`penis_length` float,
`circumcised` varchar[10],
`career_start` date,
`career_start_precision` TINYINT,
`career_end` date,
`career_end_precision` TINYINT,
`image_blob` varchar(255) REFERENCES `blobs`(`checksum`)
);
INSERT INTO `performers_new` (
`id`,
`name`,
`disambiguation`,
`gender`,
`birthdate`,
`ethnicity`,
`country`,
`eye_color`,
`height`,
`measurements`,
`fake_tits`,
`tattoos`,
`piercings`,
`favorite`,
`created_at`,
`updated_at`,
`details`,
`death_date`,
`hair_color`,
`weight`,
`rating`,
`ignore_auto_tag`,
`image_blob`,
`penis_length`,
`circumcised`,
`birthdate_precision`,
`death_date_precision`,
`career_start`,
`career_end`
) SELECT
`id`,
`name`,
`disambiguation`,
`gender`,
`birthdate`,
`ethnicity`,
`country`,
`eye_color`,
`height`,
`measurements`,
`fake_tits`,
`tattoos`,
`piercings`,
`favorite`,
`created_at`,
`updated_at`,
`details`,
`death_date`,
`hair_color`,
`weight`,
`rating`,
`ignore_auto_tag`,
`image_blob`,
`penis_length`,
`circumcised`,
`birthdate_precision`,
`death_date_precision`,
CAST(`career_start` AS TEXT),
CAST(`career_end` AS TEXT)
FROM `performers`;
DROP INDEX IF EXISTS `performers_name_disambiguation_unique`;
DROP INDEX IF EXISTS `performers_name_unique`;
DROP TABLE `performers`;
ALTER TABLE `performers_new` RENAME TO `performers`;
UPDATE "performers" SET `career_start` = CONCAT(`career_start`, '-01-01'), "career_start_precision" = 2 WHERE "career_start" IS NOT NULL;
UPDATE "performers" SET `career_end` = CONCAT(`career_end`, '-01-01'), "career_end_precision" = 2 WHERE "career_end" IS NOT NULL;
CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL;
CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL;
PRAGMA foreign_keys=ON;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -162,6 +162,11 @@ fragment ScrapedSceneTagData on ScrapedTag {
name
description
alias_list
parent {
stored_id
name
description
}
remote_site_id
}

View file

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

View file

@ -20,11 +20,6 @@
padding: 0;
}
ul {
list-style-type: none;
padding-left: 0.5rem;
}
&-version {
&-body {
padding: 1rem 2rem;

View file

@ -1,100 +1,129 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import isEqual from "lodash-es/isEqual";
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkGalleryUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form";
import { MultiSet } from "../Shared/MultiSet";
import { useToast } from "src/hooks/Toast";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateStateObject,
getAggregateTagIds,
getAggregateStudioId,
getAggregateSceneIds,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.SlimGalleryDataFragment[];
onClose: (applied: boolean) => void;
}
const galleryFields = [
"code",
"rating100",
"details",
"organized",
"photographer",
"date",
];
export const EditGalleriesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>();
const [performerMode, setPerformerMode] =
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [performerIds, setPerformerIds] = useState<string[]>();
const [existingPerformerIds, setExistingPerformerIds] = useState<string[]>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [organized, setOrganized] = useState<boolean | undefined>();
const [updateInput, setUpdateInput] = useState<GQL.BulkGalleryUpdateInput>({
ids: props.selected.map((gallery) => {
return gallery.id;
}),
});
const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [sceneIds, setSceneIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const unsetDisabled = props.selected.length < 2;
const [dateError, setDateError] = useState<string | undefined>();
const [updateGalleries] = useBulkGalleryUpdate();
// Network state
const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
const aggregateState = useMemo(() => {
const updateState: Partial<GQL.BulkGalleryUpdateInput> = {};
const state = props.selected;
updateState.studio_id = getAggregateStudioId(props.selected);
const updateTagIds = getAggregateTagIds(props.selected);
const updatePerformerIds = getAggregatePerformerIds(props.selected);
const updateSceneIds = getAggregateSceneIds(props.selected);
let first = true;
state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
getAggregateStateObject(updateState, gallery, galleryFields, first);
first = false;
});
return {
state: updateState,
tagIds: updateTagIds,
performerIds: updatePerformerIds,
sceneIds: updateSceneIds,
};
}, [props.selected]);
// update initial state from aggregate
useEffect(() => {
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
}, [aggregateState]);
useEffect(() => {
setDateError(getDateError(updateInput.date ?? "", intl));
}, [updateInput.date, intl]);
function setUpdateField(input: Partial<GQL.BulkGalleryUpdateInput>) {
setUpdateInput((current) => ({ ...current, ...input }));
}
function getGalleryInput(): GQL.BulkGalleryUpdateInput {
// need to determine what we are actually setting on each gallery
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const galleryInput: GQL.BulkGalleryUpdateInput = {
ids: props.selected.map((gallery) => {
return gallery.id;
}),
...updateInput,
tag_ids: tagIds,
performer_ids: performerIds,
scene_ids: sceneIds,
};
galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
galleryInput.studio_id = getAggregateInputValue(
studioId,
aggregateStudioId
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
galleryInput.rating100 = getAggregateInputValue(
updateInput.rating100,
aggregateState.state.rating100
);
galleryInput.performer_ids = getAggregateInputIDs(
performerMode,
performerIds,
aggregatePerformerIds
);
galleryInput.tag_ids = getAggregateInputIDs(
tagMode,
tagIds,
aggregateTagIds
);
if (organized !== undefined) {
galleryInput.organized = organized;
}
return galleryInput;
}
async function onSave() {
setIsUpdating(true);
try {
await updateGalleries({
variables: {
input: getGalleryInput(),
},
});
await updateGalleries({ variables: { input: getGalleryInput() } });
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
@ -110,129 +139,13 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;
let updateStudioID: string | undefined;
let updatePerformerIds: string[] = [];
let updateTagIds: string[] = [];
let updateOrganized: boolean | undefined;
let first = true;
state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
const galleryRating = gallery.rating100;
const GalleriestudioID = gallery?.studio?.id;
const galleryPerformerIDs = (gallery.performers ?? [])
.map((p) => p.id)
.sort();
const galleryTagIDs = (gallery.tags ?? []).map((p) => p.id).sort();
if (first) {
updateRating = galleryRating ?? undefined;
updateStudioID = GalleriestudioID;
updatePerformerIds = galleryPerformerIDs;
updateTagIds = galleryTagIDs;
updateOrganized = gallery.organized;
first = false;
} else {
if (galleryRating !== updateRating) {
updateRating = undefined;
}
if (GalleriestudioID !== updateStudioID) {
updateStudioID = undefined;
}
if (!isEqual(galleryPerformerIDs, updatePerformerIds)) {
updatePerformerIds = [];
}
if (!isEqual(galleryTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (gallery.organized !== updateOrganized) {
updateOrganized = undefined;
}
}
});
setRating(updateRating);
setStudioId(updateStudioID);
setExistingPerformerIds(updatePerformerIds);
setExistingTagIds(updateTagIds);
setOrganized(updateOrganized);
}, [props.selected]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = organized === undefined;
}
}, [organized, checkboxRef]);
function renderMultiSelect(
type: "performers" | "tags",
ids: string[] | undefined
) {
let mode = GQL.BulkUpdateIdMode.Add;
let existingIds: string[] | undefined = [];
switch (type) {
case "performers":
mode = performerMode;
existingIds = existingPerformerIds;
break;
case "tags":
mode = tagMode;
existingIds = existingTagIds;
break;
}
return (
<MultiSet
type={type}
disabled={isUpdating}
onUpdate={(itemIDs) => {
switch (type) {
case "performers":
setPerformerIds(itemIDs);
break;
case "tags":
setTagIds(itemIDs);
break;
}
}}
onSetMode={(newMode) => {
switch (type) {
case "performers":
setPerformerMode(newMode);
break;
case "tags":
setTagMode(newMode);
break;
}
}}
existingIds={existingIds ?? []}
ids={ids ?? []}
mode={mode}
menuPortalTarget={document.body}
/>
);
}
function cycleOrganized() {
if (organized) {
setOrganized(undefined);
} else if (organized === undefined) {
setOrganized(false);
} else {
setOrganized(true);
}
}
function render() {
return (
<ModalComponent
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "dialogs.edit_entity_title" },
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "gallery" }),
@ -243,6 +156,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!dateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -251,55 +165,119 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="performers">
<Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
{renderMultiSelect("performers", performerIds)}
</Form.Group>
<BulkUpdateFormGroup name="scene_code">
<BulkUpdateTextInput
value={updateInput.code}
valueChanged={(newValue) => setUpdateField({ code: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="date">
<BulkUpdateDateInput
value={updateInput.date}
valueChanged={(newValue) => setUpdateField({ date: newValue })}
unsetDisabled={unsetDisabled}
error={dateError}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
{renderMultiSelect("tags", tagIds)}
</Form.Group>
<BulkUpdateFormGroup name="photographer">
<BulkUpdateTextInput
value={updateInput.photographer}
valueChanged={(newValue) =>
setUpdateField({ photographer: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
studio_id: items.length > 0 ? items[0]?.id : undefined,
})
}
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="performers" inline={false}>
<MultiSet
type={"performers"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setPerformerIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setPerformerIds((c) => ({ ...c, mode: newMode }));
}}
ids={performerIds.ids ?? []}
existingIds={aggregateState.performerIds}
mode={performerIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="scenes" inline={false}>
<MultiSet
type={"scenes"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setSceneIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setSceneIds((c) => ({ ...c, mode: newMode }));
}}
ids={sceneIds.ids ?? []}
existingIds={aggregateState.sceneIds}
mode={sceneIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="details" inline={false}>
<BulkUpdateTextInput
value={updateInput.details}
valueChanged={(newValue) => setUpdateField({ details: newValue })}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
<Form.Group controlId="organized">
<Form.Check
type="checkbox"
<IndeterminateCheckbox
label={intl.formatMessage({ id: "organized" })}
checked={organized}
ref={checkboxRef}
onChange={() => cycleOrganized()}
setChecked={(checked) => setUpdateField({ organized: checked })}
checked={updateInput.organized ?? undefined}
/>
</Form.Group>
</Form>

View file

@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback } from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { ListFilterModel } from "src/models/list-filter/filter";
@ -24,40 +24,43 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = PatchComponent(
const Toast = useToast();
const intl = useIntl();
function filterHook(filter: ListFilterModel) {
const galleryValue = {
id: gallery.id,
label: galleryTitle(gallery),
};
// if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "galleries";
}) as GalleriesCriterion | undefined;
const filterHook = useCallback(
(filter: ListFilterModel) => {
const galleryValue = {
id: gallery.id,
label: galleryTitle(gallery),
};
// if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "galleries";
}) as GalleriesCriterion | undefined;
if (
galleryCriterion &&
galleryCriterion.modifier === GQL.CriterionModifier.Excludes
) {
// add the gallery if not present
if (
!galleryCriterion.value.find((p) => {
return p.id === gallery.id;
})
galleryCriterion &&
galleryCriterion.modifier === GQL.CriterionModifier.Excludes
) {
galleryCriterion.value.push(galleryValue);
// add the gallery if not present
if (
!galleryCriterion.value.find((p) => {
return p.id === gallery.id;
})
) {
galleryCriterion.value.push(galleryValue);
}
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
} else {
// overwrite
galleryCriterion = new GalleriesCriterion();
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
galleryCriterion.value = [galleryValue];
filter.criteria.push(galleryCriterion);
}
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
} else {
// overwrite
galleryCriterion = new GalleriesCriterion();
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
galleryCriterion.value = [galleryValue];
filter.criteria.push(galleryCriterion);
}
return filter;
}
return filter;
},
[gallery]
);
async function addImages(
result: GQL.FindImagesQueryResult,

View file

@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback } from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { ListFilterModel } from "src/models/list-filter/filter";
@ -32,40 +32,43 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
const intl = useIntl();
const Toast = useToast();
function filterHook(filter: ListFilterModel) {
const galleryValue = {
id: gallery.id!,
label: galleryTitle(gallery),
};
// if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "galleries";
}) as GalleriesCriterion | undefined;
const filterHook = useCallback(
(filter: ListFilterModel) => {
const galleryValue = {
id: gallery.id!,
label: galleryTitle(gallery),
};
// if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "galleries";
}) as GalleriesCriterion | undefined;
if (
galleryCriterion &&
(galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
galleryCriterion.modifier === GQL.CriterionModifier.Includes)
) {
// add the gallery if not present
if (
!galleryCriterion.value.find((p) => {
return p.id === gallery.id;
})
galleryCriterion &&
(galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
galleryCriterion.modifier === GQL.CriterionModifier.Includes)
) {
galleryCriterion.value.push(galleryValue);
// add the gallery if not present
if (
!galleryCriterion.value.find((p) => {
return p.id === gallery.id;
})
) {
galleryCriterion.value.push(galleryValue);
}
galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll;
} else {
// overwrite
galleryCriterion = new GalleriesCriterion();
galleryCriterion.value = [galleryValue];
filter.criteria.push(galleryCriterion);
}
galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll;
} else {
// overwrite
galleryCriterion = new GalleriesCriterion();
galleryCriterion.value = [galleryValue];
filter.criteria.push(galleryCriterion);
}
return filter;
}
return filter;
},
[gallery]
);
async function setCover(
result: GQL.FindImagesQueryResult,

View file

@ -1,26 +1,26 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkGroupUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "../Shared/Modal";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { MultiSet } from "../Shared/MultiSet";
import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateIds,
getAggregateInputIDs,
getAggregateInputValue,
getAggregateRating,
getAggregateStudioId,
getAggregateStateObject,
getAggregateTagIds,
getAggregateStudioId,
getAggregateIds,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { isEqual } from "lodash-es";
import { MultiSet } from "../Shared/MultiSet";
import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable";
import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.ListGroupDataFragment[];
@ -67,50 +67,86 @@ function getAggregateContainingGroupInput(
return undefined;
}
const groupFields = ["rating100", "synopsis", "director", "date"];
export const EditGroupsDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating100, setRating] = useState<number | undefined>();
const [studioId, setStudioId] = useState<string | undefined>();
const [director, setDirector] = useState<string | undefined>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [updateInput, setUpdateInput] = useState<GQL.BulkGroupUpdateInput>({
ids: props.selected.map((group) => {
return group.id;
}),
});
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [containingGroupsMode, setGroupMode] =
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [containingGroups, setGroups] = useState<IRelatedGroupEntry[]>();
const [existingContainingGroups, setExistingContainingGroups] =
useState<IRelatedGroupEntry[]>();
const [updateGroups] = useBulkGroupUpdate(getGroupInput());
const unsetDisabled = props.selected.length < 2;
const [updateGroups] = useBulkGroupUpdate();
const [dateError, setDateError] = useState<string | undefined>();
// Network state
const [isUpdating, setIsUpdating] = useState(false);
function getGroupInput(): GQL.BulkGroupUpdateInput {
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateState = useMemo(() => {
const updateState: Partial<GQL.BulkGroupUpdateInput> = {};
const state = props.selected;
updateState.studio_id = getAggregateStudioId(props.selected);
const updateTagIds = getAggregateTagIds(props.selected);
const aggregateGroups = getAggregateContainingGroups(props.selected);
let first = true;
state.forEach((group: GQL.ListGroupDataFragment) => {
getAggregateStateObject(updateState, group, groupFields, first);
first = false;
});
return {
state: updateState,
tagIds: updateTagIds,
containingGroups: aggregateGroups,
};
}, [props.selected]);
// update initial state from aggregate
useEffect(() => {
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
}, [aggregateState]);
useEffect(() => {
setDateError(getDateError(updateInput.date ?? "", intl));
}, [updateInput.date, intl]);
function setUpdateField(input: Partial<GQL.BulkGroupUpdateInput>) {
setUpdateInput((current) => ({ ...current, ...input }));
}
function getGroupInput(): GQL.BulkGroupUpdateInput {
const groupInput: GQL.BulkGroupUpdateInput = {
ids: props.selected.map((group) => group.id),
director,
...updateInput,
tag_ids: tagIds,
};
groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
groupInput.rating100 = getAggregateInputValue(
updateInput.rating100,
aggregateState.state.rating100
);
groupInput.containing_groups = getAggregateContainingGroupInput(
containingGroupsMode,
containingGroups,
aggregateGroups
aggregateState.containingGroups
);
return groupInput;
@ -119,13 +155,11 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
async function onSave() {
setIsUpdating(true);
try {
await updateGroups();
await updateGroups({ variables: { input: getGroupInput() } });
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase(),
}
{ entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase() }
)
);
props.onClose(true);
@ -135,67 +169,24 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;
let updateStudioId: string | undefined;
let updateTagIds: string[] = [];
let updateContainingGroupIds: IRelatedGroupEntry[] = [];
let updateDirector: string | undefined;
let first = true;
state.forEach((group: GQL.ListGroupDataFragment) => {
const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort();
const groupContainingGroupIDs = (group.containing_groups ?? []).sort(
(a, b) => a.group.id.localeCompare(b.group.id)
);
if (first) {
first = false;
updateRating = group.rating100 ?? undefined;
updateStudioId = group.studio?.id ?? undefined;
updateTagIds = groupTagIDs;
updateContainingGroupIds = groupContainingGroupIDs;
updateDirector = group.director ?? undefined;
} else {
if (group.rating100 !== updateRating) {
updateRating = undefined;
}
if (group.studio?.id !== updateStudioId) {
updateStudioId = undefined;
}
if (group.director !== updateDirector) {
updateDirector = undefined;
}
if (!isEqual(groupTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (!isEqual(groupContainingGroupIDs, updateContainingGroupIds)) {
updateTagIds = [];
}
}
});
setRating(updateRating);
setStudioId(updateStudioId);
setExistingTagIds(updateTagIds);
setExistingContainingGroups(updateContainingGroupIds);
setDirector(updateDirector);
}, [props.selected]);
function render() {
return (
<ModalComponent
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "groups" }) }
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "group" }),
pluralEntity: intl.formatMessage({ id: "groups" }),
}
)}
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!dateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -204,74 +195,90 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group controlId="containing-groups">
<Form.Label>
<FormattedMessage id="containing_groups" />
</Form.Label>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="date">
<BulkUpdateDateInput
value={updateInput.date}
valueChanged={(newValue) => setUpdateField({ date: newValue })}
unsetDisabled={unsetDisabled}
error={dateError}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="director">
<BulkUpdateTextInput
value={updateInput.director}
valueChanged={(newValue) =>
setUpdateField({ director: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
studio_id: items.length > 0 ? items[0]?.id : undefined,
})
}
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup
name="containing-groups"
messageId="containing_groups"
inline={false}
>
<ContainingGroupsMultiSet
disabled={isUpdating}
onUpdate={(v) => setGroups(v)}
onSetMode={(newMode) => setGroupMode(newMode)}
existingValue={existingContainingGroups ?? []}
existingValue={aggregateState.containingGroups ?? []}
value={containingGroups ?? []}
mode={containingGroupsMode}
menuPortalTarget={document.body}
/>
</Form.Group>
<Form.Group controlId="director">
<Form.Label>
<FormattedMessage id="director" />
</Form.Label>
<Form.Control
className="input-control"
type="text"
value={director}
onChange={(event) => setDirector(event.currentTarget.value)}
placeholder={intl.formatMessage({ id: "director" })}
/>
</Form.Group>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type="tags"
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds(itemIDs)}
onSetMode={(newMode) => setTagMode(newMode)}
existingIds={existingTagIds ?? []}
ids={tagIds ?? []}
mode={tagMode}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="synopsis" inline={false}>
<BulkUpdateTextInput
value={updateInput.synopsis}
valueChanged={(newValue) =>
setUpdateField({ synopsis: newValue })
}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
</Form>
</ModalComponent>
);

View file

@ -1,96 +1,121 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import isEqual from "lodash-es/isEqual";
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkImageUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { StudioSelect } from "src/components/Shared/Select";
import { ModalComponent } from "src/components/Shared/Modal";
import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { MultiSet } from "../Shared/MultiSet";
import { useToast } from "src/hooks/Toast";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateGalleryIds,
getAggregateInputIDs,
getAggregateInputValue,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateStateObject,
getAggregateTagIds,
getAggregateStudioId,
getAggregateGalleryIds,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.SlimImageDataFragment[];
onClose: (applied: boolean) => void;
}
const imageFields = [
"code",
"rating100",
"details",
"organized",
"photographer",
"date",
];
export const EditImagesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>();
const [performerMode, setPerformerMode] =
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [performerIds, setPerformerIds] = useState<string[]>();
const [existingPerformerIds, setExistingPerformerIds] = useState<string[]>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [updateInput, setUpdateInput] = useState<GQL.BulkImageUpdateInput>({
ids: props.selected.map((image) => {
return image.id;
}),
});
const [galleryMode, setGalleryMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [galleryIds, setGalleryIds] = useState<string[]>();
const [existingGalleryIds, setExistingGalleryIds] = useState<string[]>();
const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [galleryIds, setGalleryIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [organized, setOrganized] = useState<boolean | undefined>();
const unsetDisabled = props.selected.length < 2;
const [dateError, setDateError] = useState<string | undefined>();
const [updateImages] = useBulkImageUpdate();
// Network state
const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
const aggregateState = useMemo(() => {
const updateState: Partial<GQL.BulkImageUpdateInput> = {};
const state = props.selected;
updateState.studio_id = getAggregateStudioId(props.selected);
const updateTagIds = getAggregateTagIds(props.selected);
const updatePerformerIds = getAggregatePerformerIds(props.selected);
const updateGalleryIds = getAggregateGalleryIds(props.selected);
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
getAggregateStateObject(updateState, image, imageFields, first);
first = false;
});
return {
state: updateState,
tagIds: updateTagIds,
performerIds: updatePerformerIds,
galleryIds: updateGalleryIds,
};
}, [props.selected]);
// update initial state from aggregate
useEffect(() => {
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
}, [aggregateState]);
useEffect(() => {
setDateError(getDateError(updateInput.date ?? "", intl));
}, [updateInput.date, intl]);
function setUpdateField(input: Partial<GQL.BulkImageUpdateInput>) {
setUpdateInput((current) => ({ ...current, ...input }));
}
function getImageInput(): GQL.BulkImageUpdateInput {
// need to determine what we are actually setting on each image
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateGalleryIds = getAggregateGalleryIds(props.selected);
const imageInput: GQL.BulkImageUpdateInput = {
ids: props.selected.map((image) => {
return image.id;
}),
...updateInput,
tag_ids: tagIds,
performer_ids: performerIds,
gallery_ids: galleryIds,
};
imageInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
imageInput.performer_ids = getAggregateInputIDs(
performerMode,
performerIds,
aggregatePerformerIds
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
imageInput.rating100 = getAggregateInputValue(
updateInput.rating100,
aggregateState.state.rating100
);
imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
imageInput.gallery_ids = getAggregateInputIDs(
galleryMode,
galleryIds,
aggregateGalleryIds
);
if (organized !== undefined) {
imageInput.organized = organized;
}
return imageInput;
}
@ -98,11 +123,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
async function onSave() {
setIsUpdating(true);
try {
await updateImages({
variables: {
input: getImageInput(),
},
});
await updateImages({ variables: { input: getImageInput() } });
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
@ -116,86 +137,13 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;
let updateStudioID: string | undefined;
let updatePerformerIds: string[] = [];
let updateTagIds: string[] = [];
let updateGalleryIds: string[] = [];
let updateOrganized: boolean | undefined;
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
const imageRating = image.rating100;
const imageStudioID = image?.studio?.id;
const imagePerformerIDs = (image.performers ?? [])
.map((p) => p.id)
.sort();
const imageTagIDs = (image.tags ?? []).map((p) => p.id).sort();
const imageGalleryIDs = (image.galleries ?? []).map((p) => p.id).sort();
if (first) {
updateRating = imageRating ?? undefined;
updateStudioID = imageStudioID;
updatePerformerIds = imagePerformerIDs;
updateTagIds = imageTagIDs;
updateGalleryIds = imageGalleryIDs;
updateOrganized = image.organized;
first = false;
} else {
if (imageRating !== updateRating) {
updateRating = undefined;
}
if (imageStudioID !== updateStudioID) {
updateStudioID = undefined;
}
if (!isEqual(imagePerformerIDs, updatePerformerIds)) {
updatePerformerIds = [];
}
if (!isEqual(imageTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (!isEqual(imageGalleryIDs, updateGalleryIds)) {
updateGalleryIds = [];
}
if (image.organized !== updateOrganized) {
updateOrganized = undefined;
}
}
});
setRating(updateRating);
setStudioId(updateStudioID);
setExistingPerformerIds(updatePerformerIds);
setExistingTagIds(updateTagIds);
setExistingGalleryIds(updateGalleryIds);
setOrganized(updateOrganized);
}, [props.selected]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = organized === undefined;
}
}, [organized, checkboxRef]);
function cycleOrganized() {
if (organized) {
setOrganized(undefined);
} else if (organized === undefined) {
setOrganized(false);
} else {
setOrganized(true);
}
}
function render() {
return (
<ModalComponent
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "dialogs.edit_entity_title" },
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "image" }),
@ -206,6 +154,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!dateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -214,89 +163,120 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group controlId="performers">
<Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
<MultiSet
type="performers"
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
onUpdate={(itemIDs) => setPerformerIds(itemIDs)}
onSetMode={(newMode) => setPerformerMode(newMode)}
existingIds={existingPerformerIds ?? []}
ids={performerIds ?? []}
mode={performerMode}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="scene_code">
<BulkUpdateTextInput
value={updateInput.code}
valueChanged={(newValue) => setUpdateField({ code: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="date">
<BulkUpdateDateInput
value={updateInput.date}
valueChanged={(newValue) => setUpdateField({ date: newValue })}
unsetDisabled={unsetDisabled}
error={dateError}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="photographer">
<BulkUpdateTextInput
value={updateInput.photographer}
valueChanged={(newValue) =>
setUpdateField({ photographer: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
studio_id: items.length > 0 ? items[0]?.id : undefined,
})
}
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<BulkUpdateFormGroup name="performers" inline={false}>
<MultiSet
type="tags"
type={"performers"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds(itemIDs)}
onSetMode={(newMode) => setTagMode(newMode)}
existingIds={existingTagIds ?? []}
ids={tagIds ?? []}
mode={tagMode}
onUpdate={(itemIDs) => {
setPerformerIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setPerformerIds((c) => ({ ...c, mode: newMode }));
}}
ids={performerIds.ids ?? []}
existingIds={aggregateState.performerIds}
mode={performerIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<Form.Group controlId="galleries">
<Form.Label>
<FormattedMessage id="galleries" />
</Form.Label>
<BulkUpdateFormGroup name="galleries" inline={false}>
<MultiSet
type="galleries"
disabled={isUpdating}
onUpdate={(itemIDs) => setGalleryIds(itemIDs)}
onSetMode={(newMode) => setGalleryMode(newMode)}
existingIds={existingGalleryIds ?? []}
ids={galleryIds ?? []}
mode={galleryMode}
onUpdate={(itemIDs) => {
setGalleryIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setGalleryIds((c) => ({ ...c, mode: newMode }));
}}
ids={galleryIds.ids ?? []}
existingIds={aggregateState.galleryIds}
mode={galleryIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="details" inline={false}>
<BulkUpdateTextInput
value={updateInput.details}
valueChanged={(newValue) => setUpdateField({ details: newValue })}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
<Form.Group controlId="organized">
<Form.Check
type="checkbox"
<IndeterminateCheckbox
label={intl.formatMessage({ id: "organized" })}
checked={organized}
ref={checkboxRef}
onChange={() => cycleOrganized()}
setChecked={(checked) => setUpdateField({ organized: checked })}
checked={updateInput.organized ?? undefined}
/>
</Form.Group>
</Form>

View file

@ -751,7 +751,7 @@ export const FilteredImageList = PatchComponent(
currentPage={filter.currentPage}
itemsPerPage={filter.itemsPerPage}
totalItems={totalCount}
onChangePage={(page) => setFilter(filter.changePage(page))}
onChangePage={setPage}
/>
<PaginationIndex
loading={cachedResult.loading}
@ -766,7 +766,7 @@ export const FilteredImageList = PatchComponent(
<ImageList
filter={filter}
images={items}
onChangePage={(page) => setFilter(filter.changePage(page))}
onChangePage={setPage}
onSelectChange={onSelectChange}
pageCount={pageCount}
selectedIds={selectedIds}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Mousetrap from "mousetrap";
import { ListFilterModel } from "src/models/list-filter/filter";
import { useHistory, useLocation } from "react-router-dom";
@ -489,20 +489,20 @@ export function useCachedQueryResult<T extends QueryResult>(
result: T
) {
const [cachedResult, setCachedResult] = useState(result);
const [lastFilter, setLastFilter] = useState(filter);
const lastFilterRef = useRef(filter);
// if we are only changing the page or sort, don't update the result count
useEffect(() => {
if (!result.loading) {
setCachedResult(result);
} else {
if (totalCountImpacted(lastFilter, filter)) {
if (totalCountImpacted(lastFilterRef.current, filter)) {
setCachedResult(result);
}
}
setLastFilter(filter);
}, [filter, result, lastFilter]);
lastFilterRef.current = filter;
}, [filter, result]);
return cachedResult;
}

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkPerformerUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "../Shared/Modal";
@ -23,12 +23,13 @@ import {
stringToCircumcised,
} from "src/utils/circumcised";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import * as FormUtils from "src/utils/form";
import { CountrySelect } from "../Shared/CountrySelect";
import { useConfigurationContext } from "src/hooks/Config";
import cx from "classnames";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.SlimPerformerDataFragment[];
@ -75,17 +76,42 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
const [aggregateState, setAggregateState] =
useState<GQL.BulkPerformerUpdateInput>({});
// height and weight needs conversion to/from number
const [height, setHeight] = useState<string | undefined>();
const [weight, setWeight] = useState<string | undefined>();
const [penis_length, setPenisLength] = useState<string | undefined>();
const [height, setHeight] = useState<string | undefined | null>();
const [weight, setWeight] = useState<string | undefined | null>();
const [penis_length, setPenisLength] = useState<string | undefined | null>();
const [updateInput, setUpdateInput] = useState<GQL.BulkPerformerUpdateInput>(
{}
);
const genderOptions = [""].concat(genderStrings);
const circumcisedOptions = [""].concat(circumcisedStrings);
const unsetDisabled = props.selected.length < 2;
const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput());
const [birthdateError, setBirthdateError] = useState<string | undefined>();
const [deathDateError, setDeathDateError] = useState<string | undefined>();
const [careerStartError, setCareerStartError] = useState<
string | undefined
>();
const [careerEndError, setCareerEndError] = useState<string | undefined>();
useEffect(() => {
setBirthdateError(getDateError(updateInput.birthdate ?? "", intl));
}, [updateInput.birthdate, intl]);
useEffect(() => {
setDeathDateError(getDateError(updateInput.death_date ?? "", intl));
}, [updateInput.death_date, intl]);
useEffect(() => {
setCareerStartError(getDateError(updateInput.career_start ?? "", intl));
}, [updateInput.career_start, intl]);
useEffect(() => {
setCareerEndError(getDateError(updateInput.career_end ?? "", intl));
}, [updateInput.career_end, intl]);
// Network state
const [isUpdating, setIsUpdating] = useState(false);
@ -121,14 +147,14 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
);
if (height !== undefined) {
performerInput.height_cm = parseFloat(height);
performerInput.height_cm = height === null ? null : parseFloat(height);
}
if (weight !== undefined) {
performerInput.weight = parseFloat(weight);
performerInput.weight = weight === null ? null : parseFloat(weight);
}
if (penis_length !== undefined) {
performerInput.penis_length = parseFloat(penis_length);
performerInput.penis_length =
penis_length === null ? null : parseFloat(penis_length);
}
return performerInput;
@ -205,25 +231,6 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
setUpdateInput(updateState);
}, [props.selected]);
function renderTextField(
name: string,
value: string | undefined | null,
setter: (newValue: string | undefined) => void
) {
return (
<Form.Group controlId={name} data-field={name}>
<Form.Label>
<FormattedMessage id={name} />
</Form.Label>
<BulkUpdateTextInput
value={value === null ? "" : value ?? undefined}
valueChanged={(newValue) => setter(newValue)}
unsetDisabled={props.selected.length < 2}
/>
</Form.Group>
);
}
function render() {
// sfw class needs to be set because it is outside body
@ -235,13 +242,18 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "performers" }) }
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "performer" }),
pluralEntity: intl.formatMessage({ id: "performers" }),
}
)}
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!birthdateError || !!deathDateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -249,11 +261,8 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
}}
isRunning={isUpdating}
>
<Form.Group controlId="rating" as={Row} data-field={name}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<Form>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
@ -261,9 +270,8 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form>
</BulkUpdateFormGroup>
<Form.Group controlId="favorite">
<IndeterminateCheckbox
setChecked={(checked) => setUpdateField({ favorite: checked })}
@ -272,10 +280,7 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
/>
</Form.Group>
<Form.Group>
<Form.Label>
<FormattedMessage id="gender" />
</Form.Label>
<BulkUpdateFormGroup name="gender">
<Form.Control
as="select"
className="input-control"
@ -292,51 +297,105 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
</option>
))}
</Form.Control>
</Form.Group>
</BulkUpdateFormGroup>
{renderTextField("disambiguation", updateInput.disambiguation, (v) =>
setUpdateField({ disambiguation: v })
)}
{renderTextField("birthdate", updateInput.birthdate, (v) =>
setUpdateField({ birthdate: v })
)}
{renderTextField("death_date", updateInput.death_date, (v) =>
setUpdateField({ death_date: v })
)}
<BulkUpdateFormGroup name="disambiguation">
<BulkUpdateTextInput
value={updateInput.disambiguation}
valueChanged={(newValue) =>
setUpdateField({ disambiguation: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<Form.Group>
<Form.Label>
<FormattedMessage id="country" />
</Form.Label>
<BulkUpdateFormGroup name="birthdate">
<BulkUpdateDateInput
value={updateInput.birthdate}
valueChanged={(newValue) =>
setUpdateField({ birthdate: newValue })
}
unsetDisabled={unsetDisabled}
error={birthdateError}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="death_date">
<BulkUpdateDateInput
value={updateInput.death_date}
valueChanged={(newValue) =>
setUpdateField({ death_date: newValue })
}
unsetDisabled={unsetDisabled}
error={deathDateError}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="country">
<CountrySelect
value={updateInput.country ?? ""}
onChange={(v) => setUpdateField({ country: v })}
showFlag
/>
</Form.Group>
</BulkUpdateFormGroup>
{renderTextField("ethnicity", updateInput.ethnicity, (v) =>
setUpdateField({ ethnicity: v })
)}
{renderTextField("hair_color", updateInput.hair_color, (v) =>
setUpdateField({ hair_color: v })
)}
{renderTextField("eye_color", updateInput.eye_color, (v) =>
setUpdateField({ eye_color: v })
)}
{renderTextField("height", height, (v) => setHeight(v))}
{renderTextField("weight", weight, (v) => setWeight(v))}
{renderTextField("measurements", updateInput.measurements, (v) =>
setUpdateField({ measurements: v })
)}
{renderTextField("penis_length", penis_length, (v) =>
setPenisLength(v)
)}
<BulkUpdateFormGroup name="ethnicity">
<BulkUpdateTextInput
value={updateInput.ethnicity}
valueChanged={(newValue) =>
setUpdateField({ ethnicity: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="hair_color">
<BulkUpdateTextInput
value={updateInput.hair_color}
valueChanged={(newValue) =>
setUpdateField({ hair_color: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="eye_color">
<BulkUpdateTextInput
value={updateInput.eye_color}
valueChanged={(newValue) =>
setUpdateField({ eye_color: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="height">
<BulkUpdateTextInput
value={height}
valueChanged={(newValue) => setHeight(newValue)}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="weight">
<BulkUpdateTextInput
value={weight}
valueChanged={(newValue) => setWeight(newValue)}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="measurements">
<BulkUpdateTextInput
value={updateInput.measurements}
valueChanged={(newValue) =>
setUpdateField({ measurements: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="penis_length">
<BulkUpdateTextInput
value={penis_length}
valueChanged={(newValue) => setPenisLength(newValue)}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<Form.Group data-field="circumcised">
<Form.Label>
<FormattedMessage id="circumcised" />
</Form.Label>
<BulkUpdateFormGroup name="circumcised">
<Form.Control
as="select"
className="input-control"
@ -353,43 +412,70 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
</option>
))}
</Form.Control>
</Form.Group>
</BulkUpdateFormGroup>
{renderTextField("fake_tits", updateInput.fake_tits, (v) =>
setUpdateField({ fake_tits: v })
)}
{renderTextField("tattoos", updateInput.tattoos, (v) =>
setUpdateField({ tattoos: v })
)}
{renderTextField("piercings", updateInput.piercings, (v) =>
setUpdateField({ piercings: v })
)}
{renderTextField(
"career_start",
updateInput.career_start?.toString(),
(v) => setUpdateField({ career_start: v ? parseInt(v) : undefined })
)}
{renderTextField(
"career_end",
updateInput.career_end?.toString(),
(v) => setUpdateField({ career_end: v ? parseInt(v) : undefined })
)}
<BulkUpdateFormGroup name="fake_tits">
<BulkUpdateTextInput
value={updateInput.fake_tits}
valueChanged={(newValue) =>
setUpdateField({ fake_tits: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tattoos">
<BulkUpdateTextInput
value={updateInput.tattoos}
valueChanged={(newValue) => setUpdateField({ tattoos: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="piercings">
<BulkUpdateTextInput
value={updateInput.piercings}
valueChanged={(newValue) =>
setUpdateField({ piercings: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="career_start">
<BulkUpdateDateInput
value={updateInput.career_start}
valueChanged={(newValue) =>
setUpdateField({ career_start: newValue })
}
unsetDisabled={unsetDisabled}
error={careerStartError}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="career_end">
<BulkUpdateDateInput
value={updateInput.career_end}
valueChanged={(newValue) =>
setUpdateField({ career_end: newValue })
}
unsetDisabled={unsetDisabled}
error={careerEndError}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type="tags"
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds({ ...tagIds, ids: itemIDs })}
onSetMode={(newMode) => setTagIds({ ...tagIds, mode: newMode })}
existingIds={existingTagIds ?? []}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={existingTagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
<Form.Group controlId="ignore-auto-tags">
<IndeterminateCheckbox

View file

@ -116,11 +116,11 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
measurements: yup.string().ensure(),
fake_tits: yup.string().ensure(),
penis_length: yupInputNumber().positive().nullable().defined(),
circumcised: yupInputEnum(GQL.CircumisedEnum).nullable().defined(),
circumcised: yupInputEnum(GQL.CircumcisedEnum).nullable().defined(),
tattoos: yup.string().ensure(),
piercings: yup.string().ensure(),
career_start: yupInputNumber().positive().nullable().defined(),
career_end: yupInputNumber().positive().nullable().defined(),
career_start: yupDateString(intl),
career_end: yupDateString(intl),
urls: yupUniqueStringList(intl),
details: yup.string().ensure(),
tag_ids: yup.array(yup.string().required()).defined(),
@ -149,8 +149,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
circumcised: performer.circumcised ?? null,
tattoos: performer.tattoos ?? "",
piercings: performer.piercings ?? "",
career_start: performer.career_start ?? null,
career_end: performer.career_end ?? null,
career_start: performer.career_start ?? "",
career_end: performer.career_end ?? "",
urls: performer.urls ?? [],
details: performer.details ?? "",
tag_ids: (performer.tags ?? []).map((t) => t.id),
@ -745,8 +745,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderInputField("tattoos", "textarea")}
{renderInputField("piercings", "textarea")}
{renderInputField("career_start", "number")}
{renderInputField("career_end", "number")}
{renderDateField("career_start")}
{renderDateField("career_end")}
{renderURLListField("urls", onScrapePerformerURL, urlScrapable)}

View file

@ -8,7 +8,6 @@ import {
ScrapedTextAreaRow,
ScrapedCountryRow,
ScrapedStringListRow,
ScrapedNumberRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import { Form } from "react-bootstrap";
@ -191,7 +190,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
return;
}
let retEnum: GQL.CircumisedEnum | undefined;
let retEnum: GQL.CircumcisedEnum | undefined;
// try to translate from enum values first
const upperCircumcised = scrapedCircumcised.toUpperCase();
@ -273,14 +272,14 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.fake_tits, props.scraped.fake_tits)
);
const [careerStart, setCareerStart] = useState<ScrapeResult<number>>(
new ScrapeResult<number>(
const [careerStart, setCareerStart] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.career_start,
props.scraped.career_start
)
);
const [careerEnd, setCareerEnd] = useState<ScrapeResult<number>>(
new ScrapeResult<number>(
const [careerEnd, setCareerEnd] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.career_end,
props.scraped.career_end
)
@ -502,13 +501,13 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
result={fakeTits}
onChange={(value) => setFakeTits(value)}
/>
<ScrapedNumberRow
<ScrapedInputGroupRow
field="career_start"
title={intl.formatMessage({ id: "career_start" })}
result={careerStart}
onChange={(value) => setCareerStart(value)}
/>
<ScrapedNumberRow
<ScrapedInputGroupRow
field="career_end"
title={intl.formatMessage({ id: "career_end" })}
result={careerEnd}

View file

@ -138,14 +138,15 @@ export const FormatWeight = (weight?: number | null) => {
};
export function formatYearRange(
start?: number | null,
end?: number | null
start?: string | null,
end?: string | null
): string | undefined {
if (!start && !end) return undefined;
return `${start ?? ""} - ${end ?? ""}`;
}
export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => {
export const FormatCircumcised = (circumcised?: GQL.CircumcisedEnum | null) => {
const intl = useIntl();
if (!circumcised) {
return "";

View file

@ -20,13 +20,14 @@ import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
import {
ScrapedCustomFieldRows,
ScrapeDialogRow,
ScrapedImageRow,
ScrapedInputGroupRow,
ScrapedStringListRow,
ScrapedTextAreaRow,
} from "../Shared/ScrapeDialog/ScrapeDialogRow";
import { ModalComponent } from "../Shared/Modal";
import { sortStoredIdObjects } from "src/utils/data";
import { sortStoredIdObjects, uniqIDStoredIDs } from "src/utils/data";
import {
CustomFieldScrapeResults,
ObjectListScrapeResult,
@ -40,6 +41,7 @@ import {
} from "./PerformerDetails/PerformerScrapeDialog";
import { PerformerSelect } from "./PerformerSelect";
import { uniq } from "lodash-es";
import { StashIDsField } from "../Shared/StashID";
type MergeOptions = {
values: GQL.PerformerUpdateInput;
@ -132,6 +134,8 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
)
);
const [stashIDs, setStashIDs] = useState(new ScrapeResult<GQL.StashId[]>([]));
const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.image_path)
);
@ -166,6 +170,10 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
setLoading(false);
}
// append dest to all so that if dest has stash_ids with the same
// endpoint, then it will be excluded first
const all = sources.concat(dest);
setName(
new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)
);
@ -297,9 +305,8 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
);
setURLs(
new ScrapeResult(
dest.urls,
sources.find((s) => s.urls)?.urls,
!dest.urls?.length
dest.urls ?? [],
uniq(all.map((s) => s.urls ?? []).flat())
)
);
setGender(
@ -327,6 +334,25 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
!dest.details
)
);
setTags(
new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects(dest.tags.map(idToStoredID)),
uniqIDStoredIDs(all.map((s) => s.tags.map(idToStoredID)).flat())
)
);
setStashIDs(
new ScrapeResult(
dest.stash_ids,
all
.map((s) => s.stash_ids)
.flat()
.filter((s, index, a) => {
// remove entries with duplicate endpoints
return index === a.findIndex((ss) => ss.endpoint === s.endpoint);
})
)
);
setImage(
new ScrapeResult(
dest.image_path,
@ -583,6 +609,19 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
result={details}
onChange={(value) => setDetails(value)}
/>
<ScrapeDialogRow
field="stash_ids"
title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs}
originalField={
<StashIDsField values={stashIDs?.originalValue ?? []} />
}
newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
onChange={(value) => setStashIDs(value)}
alwaysShow={
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length
}
/>
<ScrapedImageRow
field="image"
title={intl.formatMessage({ id: "performer_image" })}
@ -630,12 +669,8 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
: undefined,
measurements: measurements.getNewValue(),
fake_tits: fakeTits.getNewValue(),
career_start: careerStart.getNewValue()
? parseInt(careerStart.getNewValue()!)
: undefined,
career_end: careerEnd.getNewValue()
? parseInt(careerEnd.getNewValue()!)
: undefined,
career_start: careerStart.getNewValue(),
career_end: careerEnd.getNewValue(),
tattoos: tattoos.getNewValue(),
piercings: piercings.getNewValue(),
urls: urls.getNewValue(),
@ -643,6 +678,7 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
circumcised: stringToCircumcised(circumcised.getNewValue()),
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
details: details.getNewValue(),
stash_ids: stashIDs.getNewValue(),
image: coverImage,
custom_fields: {
partial: Object.fromEntries(

View file

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { useIntl } from "react-intl";
import { useBulkSceneMarkerUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "../Shared/Modal";
@ -10,7 +10,7 @@ import {
getAggregateState,
getAggregateStateObject,
} from "src/utils/bulkUpdate";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { TagSelect } from "../Shared/Select";
@ -38,6 +38,8 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
mode: GQL.BulkUpdateIdMode.Add,
});
const unsetDisabled = props.selected.length < 2;
const [updateSceneMarkers] = useBulkSceneMarkerUpdate();
// Network state
@ -115,27 +117,6 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
function renderTextField(
name: string,
value: string | undefined | null,
setter: (newValue: string | undefined) => void,
area: boolean = false
) {
return (
<Form.Group controlId={name}>
<Form.Label>
<FormattedMessage id={name} />
</Form.Label>
<BulkUpdateTextInput
value={value === null ? "" : value ?? undefined}
valueChanged={(newValue) => setter(newValue)}
unsetDisabled={props.selected.length < 2}
as={area ? "textarea" : undefined}
/>
</Form.Group>
);
}
function render() {
return (
<ModalComponent
@ -143,8 +124,12 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "markers" }) }
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "marker" }),
pluralEntity: intl.formatMessage({ id: "markers" }),
}
)}
accept={{
onClick: onSave,
@ -158,39 +143,39 @@ export const EditSceneMarkersDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
{renderTextField("title", updateInput.title, (newValue) =>
setUpdateField({ title: newValue })
)}
<BulkUpdateFormGroup name="title">
<BulkUpdateTextInput
value={updateInput.title}
valueChanged={(newValue) => setUpdateField({ title: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="primary-tag">
<Form.Label>
<FormattedMessage id="primary_tag" />
</Form.Label>
<BulkUpdateFormGroup name="primary-tag" messageId="primary_tag">
<TagSelect
onSelect={(t) => setUpdateField({ primary_tag_id: t[0]?.id })}
ids={
updateInput.primary_tag_id ? [updateInput.primary_tag_id] : []
}
/>
</Form.Group>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type="tags"
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds((v) => ({ ...v, ids: itemIDs }))}
onSetMode={(newMode) =>
setTagIds((v) => ({ ...v, mode: newMode }))
}
existingIds={aggregateState.tagIds ?? []}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds ?? []}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
</Form>
</ModalComponent>
);

View file

@ -1,93 +1,121 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import isEqual from "lodash-es/isEqual";
import React, { useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkSceneUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { MultiSet } from "../Shared/MultiSet";
import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregateGroupIds,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateStateObject,
getAggregateTagIds,
getAggregateStudioId,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { BulkUpdateDateInput } from "../Shared/DateInput";
import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[];
onClose: (applied: boolean) => void;
}
const sceneFields = [
"code",
"rating100",
"details",
"organized",
"director",
"date",
];
export const EditScenesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>();
const [performerMode, setPerformerMode] =
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [performerIds, setPerformerIds] = useState<string[]>();
const [existingPerformerIds, setExistingPerformerIds] = useState<string[]>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [groupMode, setGroupMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [groupIds, setGroupIds] = useState<string[]>();
const [existingGroupIds, setExistingGroupIds] = useState<string[]>();
const [organized, setOrganized] = useState<boolean | undefined>();
const [updateScenes] = useBulkSceneUpdate(getSceneInput());
const [updateInput, setUpdateInput] = useState<GQL.BulkSceneUpdateInput>({
ids: props.selected.map((scene) => {
return scene.id;
}),
});
const [dateError, setDateError] = useState<string | undefined>();
const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [groupIds, setGroupIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const unsetDisabled = props.selected.length < 2;
const [updateScenes] = useBulkSceneUpdate();
// Network state
const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
const aggregateState = useMemo(() => {
const updateState: Partial<GQL.BulkSceneUpdateInput> = {};
const state = props.selected;
updateState.studio_id = getAggregateStudioId(props.selected);
const updateTagIds = getAggregateTagIds(props.selected);
const updatePerformerIds = getAggregatePerformerIds(props.selected);
const updateGroupIds = getAggregateGroupIds(props.selected);
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
getAggregateStateObject(updateState, scene, sceneFields, first);
first = false;
});
return {
state: updateState,
tagIds: updateTagIds,
performerIds: updatePerformerIds,
groupIds: updateGroupIds,
};
}, [props.selected]);
// update initial state from aggregate
useEffect(() => {
setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
}, [aggregateState]);
useEffect(() => {
setDateError(getDateError(updateInput.date ?? "", intl));
}, [updateInput.date, intl]);
function setUpdateField(input: Partial<GQL.BulkSceneUpdateInput>) {
setUpdateInput((current) => ({ ...current, ...input }));
}
function getSceneInput(): GQL.BulkSceneUpdateInput {
// need to determine what we are actually setting on each scene
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateGroupIds = getAggregateGroupIds(props.selected);
const sceneInput: GQL.BulkSceneUpdateInput = {
ids: props.selected.map((scene) => {
return scene.id;
}),
...updateInput,
tag_ids: tagIds,
performer_ids: performerIds,
group_ids: groupIds,
};
sceneInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
sceneInput.performer_ids = getAggregateInputIDs(
performerMode,
performerIds,
aggregatePerformerIds
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
sceneInput.rating100 = getAggregateInputValue(
updateInput.rating100,
aggregateState.state.rating100
);
sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
sceneInput.group_ids = getAggregateInputIDs(
groupMode,
groupIds,
aggregateGroupIds
);
if (organized !== undefined) {
sceneInput.organized = organized;
}
return sceneInput;
}
@ -95,7 +123,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
async function onSave() {
setIsUpdating(true);
try {
await updateScenes();
await updateScenes({ variables: { input: getSceneInput() } });
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
@ -109,145 +137,13 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;
let updateStudioID: string | undefined;
let updatePerformerIds: string[] = [];
let updateTagIds: string[] = [];
let updateGroupIds: string[] = [];
let updateOrganized: boolean | undefined;
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
const sceneRating = scene.rating100;
const sceneStudioID = scene?.studio?.id;
const scenePerformerIDs = (scene.performers ?? [])
.map((p) => p.id)
.sort();
const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort();
const sceneGroupIDs = (scene.groups ?? []).map((m) => m.group.id).sort();
if (first) {
updateRating = sceneRating ?? undefined;
updateStudioID = sceneStudioID;
updatePerformerIds = scenePerformerIDs;
updateTagIds = sceneTagIDs;
updateGroupIds = sceneGroupIDs;
first = false;
updateOrganized = scene.organized;
} else {
if (sceneRating !== updateRating) {
updateRating = undefined;
}
if (sceneStudioID !== updateStudioID) {
updateStudioID = undefined;
}
if (!isEqual(scenePerformerIDs, updatePerformerIds)) {
updatePerformerIds = [];
}
if (!isEqual(sceneTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (!isEqual(sceneGroupIDs, updateGroupIds)) {
updateGroupIds = [];
}
if (scene.organized !== updateOrganized) {
updateOrganized = undefined;
}
}
});
setRating(updateRating);
setStudioId(updateStudioID);
setExistingPerformerIds(updatePerformerIds);
setExistingTagIds(updateTagIds);
setExistingGroupIds(updateGroupIds);
setOrganized(updateOrganized);
}, [props.selected]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = organized === undefined;
}
}, [organized, checkboxRef]);
function renderMultiSelect(
type: "performers" | "tags" | "groups",
ids: string[] | undefined
) {
let mode = GQL.BulkUpdateIdMode.Add;
let existingIds: string[] | undefined = [];
switch (type) {
case "performers":
mode = performerMode;
existingIds = existingPerformerIds;
break;
case "tags":
mode = tagMode;
existingIds = existingTagIds;
break;
case "groups":
mode = groupMode;
existingIds = existingGroupIds;
break;
}
return (
<MultiSet
type={type}
disabled={isUpdating}
onUpdate={(itemIDs) => {
switch (type) {
case "performers":
setPerformerIds(itemIDs);
break;
case "tags":
setTagIds(itemIDs);
break;
case "groups":
setGroupIds(itemIDs);
break;
}
}}
onSetMode={(newMode) => {
switch (type) {
case "performers":
setPerformerMode(newMode);
break;
case "tags":
setTagMode(newMode);
break;
case "groups":
setGroupMode(newMode);
break;
}
}}
ids={ids ?? []}
existingIds={existingIds ?? []}
mode={mode}
menuPortalTarget={document.body}
/>
);
}
function cycleOrganized() {
if (organized) {
setOrganized(undefined);
} else if (organized === undefined) {
setOrganized(false);
} else {
setOrganized(true);
}
}
function render() {
return (
<ModalComponent
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "dialogs.edit_entity_title" },
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "scene" }),
@ -258,6 +154,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
disabled={isUpdating || !!dateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@ -266,62 +163,121 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="performers">
<Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
{renderMultiSelect("performers", performerIds)}
</Form.Group>
<BulkUpdateFormGroup name="scene_code">
<BulkUpdateTextInput
value={updateInput.code}
valueChanged={(newValue) => setUpdateField({ code: newValue })}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
{renderMultiSelect("tags", tagIds)}
</Form.Group>
<BulkUpdateFormGroup name="date">
<BulkUpdateDateInput
value={updateInput.date}
valueChanged={(newValue) => setUpdateField({ date: newValue })}
unsetDisabled={unsetDisabled}
error={dateError}
/>
</BulkUpdateFormGroup>
<Form.Group controlId="groups">
<Form.Label>
<FormattedMessage id="groups" />
</Form.Label>
{renderMultiSelect("groups", groupIds)}
</Form.Group>
<BulkUpdateFormGroup name="director">
<BulkUpdateTextInput
value={updateInput.director}
valueChanged={(newValue) =>
setUpdateField({ director: newValue })
}
unsetDisabled={unsetDisabled}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
studio_id: items.length > 0 ? items[0]?.id : undefined,
})
}
ids={updateInput.studio_id ? [updateInput.studio_id] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="performers" inline={false}>
<MultiSet
type={"performers"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setPerformerIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setPerformerIds((c) => ({ ...c, mode: newMode }));
}}
ids={performerIds.ids ?? []}
existingIds={aggregateState.performerIds}
mode={performerIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="groups" inline={false}>
<MultiSet
type={"groups"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setGroupIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setGroupIds((c) => ({ ...c, mode: newMode }));
}}
ids={groupIds.ids ?? []}
existingIds={aggregateState.groupIds}
mode={groupIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="details" inline={false}>
<BulkUpdateTextInput
value={updateInput.details}
valueChanged={(newValue) => setUpdateField({ details: newValue })}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
<Form.Group controlId="organized">
<Form.Check
type="checkbox"
<IndeterminateCheckbox
label={intl.formatMessage({ id: "organized" })}
checked={organized}
ref={checkboxRef}
onChange={() => cycleOrganized()}
setChecked={(checked) => setUpdateField({ organized: checked })}
checked={updateInput.organized ?? undefined}
/>
</Form.Group>
</Form>

View file

@ -352,6 +352,39 @@ const SceneCardOverlays = PatchComponent(
}
);
interface ISceneSpecsOverlay {
scene: GQL.SlimSceneDataFragment;
}
export const SceneSpecsOverlay: React.FC<ISceneSpecsOverlay> = PatchComponent(
"SceneCard.SceneSpecs",
({ scene }) => {
const file = scene.files?.[0];
if (!file) return null;
return (
<div className="scene-specs-overlay">
<span className="overlay-filesize extra-scene-info">
<FileSize size={file.size} />
</span>
{file.width && file.height ? (
<span className="overlay-resolution">
{TextUtils.resolution(file.width, file.height)}
</span>
) : (
""
)}
{file.duration > 0 ? (
<span className="overlay-duration">
{TextUtils.secondsToTimestamp(file.duration)}
</span>
) : (
""
)}
</div>
);
}
);
const SceneCardImage = PatchComponent(
"SceneCard.Image",
(props: ISceneCardProps) => {
@ -364,35 +397,6 @@ const SceneCardImage = PatchComponent(
[props.scene]
);
function maybeRenderSceneSpecsOverlay() {
return (
<div className="scene-specs-overlay">
{file?.size !== undefined ? (
<span className="overlay-filesize extra-scene-info">
<FileSize size={file.size} />
</span>
) : (
""
)}
{file?.width && file?.height ? (
<span className="overlay-resolution">
{" "}
{TextUtils.resolution(file?.width, file?.height)}
</span>
) : (
""
)}
{(file?.duration ?? 0) >= 1 ? (
<span className="overlay-duration">
{TextUtils.secondsToTimestamp(file?.duration ?? 0)}
</span>
) : (
""
)}
</div>
);
}
function maybeRenderInteractiveSpeedOverlay() {
return (
<div className="scene-interactive-speed-overlay">
@ -432,7 +436,7 @@ const SceneCardImage = PatchComponent(
disabled={props.selecting}
/>
<RatingBanner rating={props.scene.rating100} />
{maybeRenderSceneSpecsOverlay()}
<SceneSpecsOverlay scene={props.scene} />
{maybeRenderInteractiveSpeedOverlay()}
</>
);

View file

@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "../Shared/Icon";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { StringListSelect, GallerySelect } from "../Shared/Select";
import { GallerySelect } from "../Shared/Select";
import * as FormUtils from "src/utils/form";
import ImageUtils from "src/utils/image";
import TextUtils from "src/utils/text";
@ -26,7 +26,7 @@ import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
import { clone, uniq } from "lodash-es";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { ModalComponent } from "../Shared/Modal";
import { IHasStoredID, sortStoredIdObjects } from "src/utils/data";
import { sortStoredIdObjects, uniqIDStoredIDs } from "src/utils/data";
import {
CustomFieldScrapeResults,
ObjectListScrapeResult,
@ -41,14 +41,7 @@ import {
ScrapedTagsRow,
} from "../Shared/ScrapeDialog/ScrapedObjectsRow";
import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect";
interface IStashIDsField {
values: GQL.StashId[];
}
const StashIDsField: React.FC<IStashIDsField> = ({ values }) => {
return <StringListSelect value={values.map((v) => v.stash_id)} />;
};
import { StashIDsField } from "../Shared/StashID";
type MergeOptions = {
values: GQL.SceneUpdateInput;
@ -132,12 +125,6 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
return ret;
}
function uniqIDStoredIDs<T extends IHasStoredID>(objs: T[]) {
return objs.filter((o, i) => {
return objs.findIndex((oo) => oo.stored_id === o.stored_id) === i;
});
}
const [performers, setPerformers] = useState<
ObjectListScrapeResult<GQL.ScrapedPerformer>
>(
@ -604,6 +591,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
}
newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
onChange={(value) => setStashIDs(value)}
alwaysShow={
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length
}
/>
<ScrapedImageRow
field="cover_image"

View file

@ -190,6 +190,10 @@ textarea.scene-description {
right: 0.7rem;
}
.scene-specs-overlay > span:not(:last-child) {
margin-right: 0.3rem;
}
.scene-interactive-speed-overlay {
left: 0.7rem;
}
@ -200,7 +204,6 @@ textarea.scene-description {
.overlay-resolution {
font-weight: 900;
margin-right: 0.3rem;
text-transform: uppercase;
}

View file

@ -200,6 +200,7 @@ export const SettingsInterfacePanel: React.FC = PatchComponent(
onChange={(v) => saveInterface({ language: v })}
>
<option value="af-ZA">Afrikaans (Preview)</option>
<option value="ar">Arabic (Preview)</option>
<option value="bg-BG">Bulgarian (Preview)</option>
<option value="bn-BD"> () (Preview)</option>
<option value="ca-ES">Catalan (Preview)</option>

View file

@ -145,6 +145,13 @@ const CleanOptions: React.FC<ICleanOptions> = ({
return (
<>
<BooleanSetting
id="clean-ignore-zip-contents"
checked={options.ignoreZipFileContents ?? false}
headingID="config.tasks.clean_ignore_zip_contents"
subHeadingID="config.tasks.clean_ignore_zip_contents_desc"
onChange={(v) => setOptions({ ignoreZipFileContents: v })}
/>
<BooleanSetting
id="clean-dryrun"
checked={options.dryRun}

View file

@ -0,0 +1,242 @@
import React, { useMemo, useRef, useState } from "react";
import { Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { ModalComponent } from "src/components/Shared/Modal";
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
interface IEntityWithStashIDs {
stash_ids: { endpoint: string }[];
}
interface IBatchUpdateModalProps {
entities: IEntityWithStashIDs[];
isIdle: boolean;
selectedEndpoint: { endpoint: string; index: number };
allCount: number | undefined;
onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;
onRefreshChange?: (refresh: boolean) => void;
batchAddParents: boolean;
setBatchAddParents: (addParents: boolean) => void;
close: () => void;
localePrefix: string;
entityName: string;
countVariableName: string;
}
export const BatchUpdateModal: React.FC<IBatchUpdateModalProps> = ({
entities,
isIdle,
selectedEndpoint,
allCount,
onBatchUpdate,
onRefreshChange,
batchAddParents,
setBatchAddParents,
close,
localePrefix,
entityName,
countVariableName,
}) => {
const intl = useIntl();
const [queryAll, setQueryAll] = useState(false);
const [refresh, setRefreshState] = useState(false);
const setRefresh = (value: boolean) => {
setRefreshState(value);
onRefreshChange?.(value);
};
const entityCount = useMemo(() => {
const filteredStashIDs = entities.map((e) =>
e.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)
);
return queryAll
? allCount
: filteredStashIDs.filter((s) =>
refresh ? s.length > 0 : s.length === 0
).length;
}, [queryAll, refresh, entities, allCount, selectedEndpoint.endpoint]);
return (
<ModalComponent
show
icon={faTags}
header={intl.formatMessage({
id: `${localePrefix}.update_${entityName}s`,
})}
accept={{
text: intl.formatMessage({
id: `${localePrefix}.update_${entityName}s`,
}),
onClick: () => onBatchUpdate(queryAll, refresh),
}}
cancel={{
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger",
onClick: () => close(),
}}
disabled={!isIdle}
>
<Form.Group>
<Form.Label>
<h6>
<FormattedMessage id={`${localePrefix}.${entityName}_selection`} />
</h6>
</Form.Label>
<Form.Check
id="query-page"
type="radio"
name={`${entityName}-query`}
label={<FormattedMessage id={`${localePrefix}.current_page`} />}
checked={!queryAll}
onChange={() => setQueryAll(false)}
/>
<Form.Check
id="query-all"
type="radio"
name={`${entityName}-query`}
label={intl.formatMessage({
id: `${localePrefix}.query_all_${entityName}s_in_the_database`,
})}
checked={queryAll}
onChange={() => setQueryAll(true)}
/>
</Form.Group>
<Form.Group>
<Form.Label>
<h6>
<FormattedMessage id={`${localePrefix}.tag_status`} />
</h6>
</Form.Label>
<Form.Check
id={`untagged-${entityName}s`}
type="radio"
name={`${entityName}-refresh`}
label={intl.formatMessage({
id: `${localePrefix}.untagged_${entityName}s`,
})}
checked={!refresh}
onChange={() => setRefresh(false)}
/>
<Form.Text>
<FormattedMessage
id={`${localePrefix}.updating_untagged_${entityName}s_description`}
/>
</Form.Text>
<Form.Check
id={`tagged-${entityName}s`}
type="radio"
name={`${entityName}-refresh`}
label={intl.formatMessage({
id: `${localePrefix}.refresh_tagged_${entityName}s`,
})}
checked={refresh}
onChange={() => setRefresh(true)}
/>
<Form.Text>
<FormattedMessage
id={`${localePrefix}.refreshing_will_update_the_data`}
/>
</Form.Text>
</Form.Group>
<div className="mt-4">
<Form.Check
id="add-parent"
checked={batchAddParents}
label={intl.formatMessage({
id: `${localePrefix}.create_or_tag_parent_${entityName}s`,
})}
onChange={() => setBatchAddParents(!batchAddParents)}
/>
</div>
<b>
<FormattedMessage
id={`${localePrefix}.number_of_${entityName}s_will_be_processed`}
values={{
[countVariableName]: entityCount,
}}
/>
</b>
</ModalComponent>
);
};
interface IBatchAddModalProps {
isIdle: boolean;
onBatchAdd: (input: string) => void;
batchAddParents: boolean;
setBatchAddParents: (addParents: boolean) => void;
close: () => void;
localePrefix: string;
entityName: string;
}
export const BatchAddModal: React.FC<IBatchAddModalProps> = ({
isIdle,
onBatchAdd,
batchAddParents,
setBatchAddParents,
close,
localePrefix,
entityName,
}) => {
const intl = useIntl();
const inputRef = useRef<HTMLTextAreaElement | null>(null);
return (
<ModalComponent
show
icon={faStar}
header={intl.formatMessage({
id: `${localePrefix}.add_new_${entityName}s`,
})}
accept={{
text: intl.formatMessage({
id: `${localePrefix}.add_new_${entityName}s`,
}),
onClick: () => {
if (inputRef.current) {
onBatchAdd(inputRef.current.value);
} else {
close();
}
},
}}
cancel={{
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger",
onClick: () => close(),
}}
disabled={!isIdle}
>
<Form.Control
className="text-input"
as="textarea"
ref={inputRef}
placeholder={intl.formatMessage({
id: `${localePrefix}.${entityName}_names_or_stashids_separated_by_comma`,
})}
rows={6}
/>
<Form.Text>
<FormattedMessage
id={`${localePrefix}.any_names_entered_will_be_queried`}
/>
</Form.Text>
<div className="mt-2">
<Form.Check
id="add-parent"
checked={batchAddParents}
label={intl.formatMessage({
id: `${localePrefix}.create_or_tag_parent_${entityName}s`,
})}
onChange={() => setBatchAddParents(!batchAddParents)}
/>
</div>
</ModalComponent>
);
};

View file

@ -0,0 +1,89 @@
import { faBan } from "@fortawesome/free-solid-svg-icons";
import React from "react";
import {
Button,
Col,
Form,
FormControlProps,
InputGroup,
Row,
} from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "./Icon";
import * as FormUtils from "src/utils/form";
interface IBulkUpdateTextInputProps extends Omit<FormControlProps, "value"> {
valueChanged: (value: string | null | undefined) => void;
value: string | null | undefined;
unsetDisabled?: boolean;
as?: React.ElementType;
}
export const BulkUpdateTextInput: React.FC<IBulkUpdateTextInputProps> = ({
valueChanged,
unsetDisabled,
...props
}) => {
const intl = useIntl();
const value = props.value === null ? "" : props.value ?? undefined;
const unset = value === undefined;
const placeholderValue = unset
? `<${intl.formatMessage({ id: "existing_value" })}>`
: value === ""
? `<${intl.formatMessage({ id: "empty_value" })}>`
: undefined;
return (
<InputGroup className="bulk-update-text-input">
<Form.Control
{...props}
className="text-input"
type="text"
as={props.as}
value={value ?? ""}
placeholder={placeholderValue}
onChange={(event) => valueChanged(event.currentTarget.value)}
/>
<InputGroup.Append>
{!unsetDisabled ? (
<Button
variant="secondary"
onClick={() => valueChanged(undefined)}
title={intl.formatMessage({ id: "actions.unset" })}
disabled={unset}
>
<Icon icon={faBan} />
</Button>
) : undefined}
</InputGroup.Append>
</InputGroup>
);
};
export const BulkUpdateFormGroup: React.FC<{
name: string;
messageId?: string;
inline?: boolean;
}> = ({ name, messageId = name, inline = true, children }) => {
if (inline) {
return (
<Form.Group controlId={name} data-field={name} as={Row}>
{FormUtils.renderLabel({
title: <FormattedMessage id={messageId} />,
})}
<Col xs={9}>{children}</Col>
</Form.Group>
);
}
return (
<Form.Group controlId={name} data-field={name}>
<Form.Label>
<FormattedMessage id={messageId} />
</Form.Label>
{children}
</Form.Group>
);
};

View file

@ -1,48 +0,0 @@
import { faBan } from "@fortawesome/free-solid-svg-icons";
import React from "react";
import { Button, Form, FormControlProps, InputGroup } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Icon } from "./Icon";
interface IBulkUpdateTextInputProps extends FormControlProps {
valueChanged: (value: string | undefined) => void;
unsetDisabled?: boolean;
as?: React.ElementType;
}
export const BulkUpdateTextInput: React.FC<IBulkUpdateTextInputProps> = ({
valueChanged,
unsetDisabled,
...props
}) => {
const intl = useIntl();
const unsetClassName = props.value === undefined ? "unset" : "";
return (
<InputGroup className={`bulk-update-text-input ${unsetClassName}`}>
<Form.Control
{...props}
className="input-control"
type="text"
as={props.as}
value={props.value ?? ""}
placeholder={
props.value === undefined
? `<${intl.formatMessage({ id: "existing_value" })}>`
: undefined
}
onChange={(event) => valueChanged(event.currentTarget.value)}
/>
{!unsetDisabled ? (
<Button
variant="secondary"
onClick={() => valueChanged(undefined)}
title={intl.formatMessage({ id: "actions.unset" })}
>
<Icon icon={faBan} />
</Button>
) : undefined}
</InputGroup>
);
};

View file

@ -8,14 +8,20 @@ import { Icon } from "./Icon";
import "react-datepicker/dist/react-datepicker.css";
import { useIntl } from "react-intl";
import { PatchComponent } from "src/patch";
import { faBan, faTimes } from "@fortawesome/free-solid-svg-icons";
interface IProps {
groupClassName?: string;
className?: string;
disabled?: boolean;
value: string;
isTime?: boolean;
onValueChange(value: string): void;
placeholder?: string;
placeholderOverride?: string;
error?: string;
appendBefore?: React.ReactNode;
appendAfter?: React.ReactNode;
}
const ShowPickerButton = forwardRef<
@ -32,6 +38,11 @@ const ShowPickerButton = forwardRef<
const _DateInput: React.FC<IProps> = (props: IProps) => {
const intl = useIntl();
const {
groupClassName = "date-input-group",
className = "date-input text-input",
} = props;
const date = useMemo(() => {
const toDate = props.isTime
? TextUtils.stringToFuzzyDateTime
@ -70,34 +81,108 @@ const _DateInput: React.FC<IProps> = (props: IProps) => {
}
}
const placeholderText = intl.formatMessage({
const formatHint = intl.formatMessage({
id: props.isTime ? "datetime_format" : "date_format",
});
const placeholderText = props.placeholder
? `${props.placeholder} (${formatHint})`
: formatHint;
return (
<div>
<InputGroup hasValidation>
<Form.Control
className="date-input text-input"
disabled={props.disabled}
value={props.value}
onChange={(e) => props.onValueChange(e.currentTarget.value)}
placeholder={
!props.disabled
? props.placeholder
? `${props.placeholder} (${placeholderText})`
: placeholderText
: undefined
}
isInvalid={!!props.error}
/>
<InputGroup.Append>{maybeRenderButton()}</InputGroup.Append>
<Form.Control.Feedback type="invalid">
{props.error}
</Form.Control.Feedback>
</InputGroup>
</div>
<InputGroup hasValidation className={groupClassName}>
<Form.Control
className={className}
disabled={props.disabled}
value={props.value}
onChange={(e) => props.onValueChange(e.currentTarget.value)}
placeholder={
!props.disabled
? props.placeholderOverride ?? placeholderText
: undefined
}
isInvalid={!!props.error}
/>
<InputGroup.Append>
{props.appendBefore}
{maybeRenderButton()}
{props.appendAfter}
</InputGroup.Append>
<Form.Control.Feedback type="invalid">
{props.error}
</Form.Control.Feedback>
</InputGroup>
);
};
export const DateInput = PatchComponent("DateInput", _DateInput);
interface IBulkUpdateDateInputProps
extends Omit<IProps, "onValueChange" | "value"> {
value: string | null | undefined;
valueChanged: (value: string | null | undefined) => void;
unsetDisabled?: boolean;
as?: React.ElementType;
error?: string;
}
export const BulkUpdateDateInput: React.FC<IBulkUpdateDateInputProps> = ({
valueChanged,
unsetDisabled,
...props
}) => {
const intl = useIntl();
const unset = props.value === undefined;
const unsetButton = !unsetDisabled ? (
<Button
variant="secondary"
onClick={() => valueChanged(undefined)}
title={intl.formatMessage({ id: "actions.unset" })}
disabled={unset}
>
<Icon icon={faBan} />
</Button>
) : undefined;
const clearButton =
props.value !== null ? (
<Button
className="minimal"
variant="secondary"
onClick={() => valueChanged(null)}
title={intl.formatMessage({ id: "actions.clear" })}
>
<Icon icon={faTimes} />
</Button>
) : undefined;
const placeholderValue =
props.value === null
? `<${intl.formatMessage({ id: "empty_value" })}>`
: props.value === undefined
? `<${intl.formatMessage({ id: "existing_value" })}>`
: undefined;
function outValue(v: string | undefined) {
if (v === "") {
return null;
}
return v;
}
return (
<DateInput
{...props}
value={props.value ?? ""}
placeholderOverride={placeholderValue}
onValueChange={(v) => valueChanged(outValue(v))}
groupClassName="bulk-update-date-input"
className="date-input text-input"
appendBefore={clearButton}
appendAfter={unsetButton}
/>
);
};

View file

@ -58,7 +58,6 @@ const SelectComponent = <T, IsMulti extends boolean>(
) => {
const {
selectedOptions,
isLoading,
isDisabled = false,
creatable = false,
components,
@ -101,10 +100,7 @@ const SelectComponent = <T, IsMulti extends boolean>(
};
return creatable ? (
<AsyncCreatableSelect
{...componentProps}
isDisabled={isLoading || isDisabled}
/>
<AsyncCreatableSelect {...componentProps} isDisabled={isDisabled} />
) : (
<AsyncSelect {...componentProps} />
);

View file

@ -12,9 +12,10 @@ import { PerformerIDSelect } from "../Performers/PerformerSelect";
import { StudioIDSelect } from "../Studios/StudioSelect";
import { TagIDSelect } from "../Tags/TagSelect";
import { GroupIDSelect } from "../Groups/GroupSelect";
import { SceneIDSelect } from "../Scenes/SceneSelect";
interface IMultiSetProps {
type: "performers" | "studios" | "tags" | "groups" | "galleries";
type: "performers" | "studios" | "tags" | "groups" | "galleries" | "scenes";
existingIds?: string[];
ids?: string[];
mode: GQL.BulkUpdateIdMode;
@ -89,6 +90,17 @@ const Select: React.FC<IMultiSetProps> = (props) => {
menuPortalTarget={props.menuPortalTarget}
/>
);
case "scenes":
return (
<SceneIDSelect
isDisabled={disabled}
isMulti
isClearable={false}
onSelect={onUpdate}
ids={props.ids ?? []}
menuPortalTarget={props.menuPortalTarget}
/>
);
default:
return (
<FilterSelect

View file

@ -40,6 +40,7 @@ interface IScrapedRowProps<T> extends IScrapedFieldProps<T> {
newField: React.ReactNode;
onChange: (value: ScrapeResult<T>) => void;
newValues?: React.ReactNode;
alwaysShow?: boolean;
}
export const ScrapeDialogRow = <T,>(props: IScrapedRowProps<T>) => {
@ -51,7 +52,7 @@ export const ScrapeDialogRow = <T,>(props: IScrapedRowProps<T>) => {
props.onChange(ret);
}
if (!props.result.scraped && !props.newValues) {
if (!props.result.scraped && !props.newValues && !props.alwaysShow) {
return <></>;
}

View file

@ -31,3 +31,21 @@ export const StashIDPill: React.FC<{
</span>
);
};
interface IStashIDsField {
values: StashId[];
}
export const StashIDsField: React.FC<IStashIDsField> = ({ values }) => {
if (!values.length) return null;
return (
<ul className="pl-0 mw-100">
{values.map((v) => (
<li key={v.stash_id} className="row no-gutters">
<StashIDPill linkType="scenes" stashID={v} />
</li>
))}
</ul>
);
};

View file

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

View file

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useBulkStudioUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "../Shared/Modal";
@ -13,9 +13,8 @@ import {
getAggregateStateObject,
} from "src/utils/bulkUpdate";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import * as FormUtils from "src/utils/form";
import { StudioSelect } from "../Shared/Select";
interface IListOperationProps {
@ -47,6 +46,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
mode: GQL.BulkUpdateIdMode.Add,
});
const unsetDisabled = props.selected.length < 2;
const [updateStudios] = useBulkStudioUpdate();
// Network state
@ -126,27 +127,6 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
function renderTextField(
name: string,
value: string | undefined | null,
setter: (newValue: string | undefined) => void,
area: boolean = false
) {
return (
<Form.Group controlId={name}>
<Form.Label>
<FormattedMessage id={name} />
</Form.Label>
<BulkUpdateTextInput
value={value === null ? "" : value ?? undefined}
valueChanged={(newValue) => setter(newValue)}
unsetDisabled={props.selected.length < 2}
as={area ? "textarea" : undefined}
/>
</Form.Group>
);
}
function render() {
return (
<ModalComponent
@ -154,8 +134,12 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "studios" }) }
{ id: "dialogs.edit_entity_count_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "studio" }),
pluralEntity: intl.formatMessage({ id: "studios" }),
}
)}
accept={{
onClick: onSave,
@ -168,11 +152,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
}}
isRunning={isUpdating}
>
<Form.Group controlId="parent-studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "parent_studio" }),
})}
<Col xs={9}>
<Form>
<BulkUpdateFormGroup name="parent-studio" messageId="parent_studio">
<StudioSelect
onSelect={(items) =>
setUpdateField({
@ -183,13 +164,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
</BulkUpdateFormGroup>
<BulkUpdateFormGroup name="rating">
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
@ -197,9 +173,8 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form>
</BulkUpdateFormGroup>
<Form.Group controlId="favorite">
<IndeterminateCheckbox
setChecked={(checked) => setUpdateField({ favorite: checked })}
@ -208,30 +183,31 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
/>
</Form.Group>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<BulkUpdateFormGroup name="tags" inline={false}>
<MultiSet
type="tags"
type={"tags"}
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds((v) => ({ ...v, ids: itemIDs }))}
onSetMode={(newMode) =>
setTagIds((v) => ({ ...v, mode: newMode }))
}
existingIds={aggregateState.tagIds ?? []}
onUpdate={(itemIDs) => {
setTagIds((c) => ({ ...c, ids: itemIDs }));
}}
onSetMode={(newMode) => {
setTagIds((c) => ({ ...c, mode: newMode }));
}}
ids={tagIds.ids ?? []}
existingIds={aggregateState.tagIds}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>
</BulkUpdateFormGroup>
{renderTextField(
"details",
updateInput.details,
(newValue) => setUpdateField({ details: newValue }),
true
)}
<BulkUpdateFormGroup name="details" inline={false}>
<BulkUpdateTextInput
value={updateInput.details}
valueChanged={(newValue) => setUpdateField({ details: newValue })}
unsetDisabled={unsetDisabled}
as="textarea"
/>
</BulkUpdateFormGroup>
<Form.Group controlId="ignore-auto-tags">
<IndeterminateCheckbox

View file

@ -15,10 +15,10 @@ import {
faArrowLeft,
faArrowRight,
faCheck,
faExternalLinkAlt,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { ExternalLink } from "../Shared/ExternalLink";
import { StashIDPill } from "../Shared/StashID";
interface IPerformerModalProps {
performer: GQL.ScrapedScenePerformerDataFragment;
@ -208,15 +208,13 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
function maybeRenderStashBoxLink() {
const base = endpoint?.match(/https?:\/\/.*?\//)?.[0];
if (!base) return;
if (!base || !performer.remote_site_id) return;
return (
<h6 className="mt-2">
<ExternalLink href={`${base}performers/${performer.remote_site_id}`}>
<FormattedMessage id="stashbox.source" />
<Icon icon={faExternalLinkAlt} className="ml-2" />
</ExternalLink>
</h6>
<StashIDPill
linkType="performers"
stashID={{ endpoint: endpoint, stash_id: performer.remote_site_id }}
/>
);
}

View file

@ -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"];

View file

@ -11,7 +11,10 @@ import { StashIDPill } from "src/components/Shared/StashID";
import { PerformerLink, TagLink } from "src/components/Shared/TagLink";
import { TruncatedText } from "src/components/Shared/TruncatedText";
import { parsePath, prepareQueryString } from "src/components/Tagger/utils";
import { ScenePreview } from "src/components/Scenes/SceneCard";
import {
ScenePreview,
SceneSpecsOverlay,
} from "src/components/Scenes/SceneCard";
import { TaggerStateContext } from "../context";
import {
faChevronDown,
@ -271,6 +274,7 @@ export const TaggerScene: React.FC<PropsWithChildren<ITaggerScene>> = ({
vttPath={scene.paths.vtt ?? undefined}
onScrubberClick={onScrubberClick}
/>
<SceneSpecsOverlay scene={scene} />
{maybeRenderSpriteIcon()}
</Link>
</div>

Some files were not shown because too many files have changed in this diff Show more