Multiple image URLs (#4000)

* Backend changes - ported from scene impl
* Front end changes
* Refactor URL mutation code
This commit is contained in:
WithoutPants
2023-09-12 13:31:53 +10:00
committed by GitHub
parent 9f4d0af886
commit a25286bdcb
29 changed files with 457 additions and 173 deletions

View File

@@ -2,7 +2,7 @@ fragment SlimImageData on Image {
id id
title title
date date
url urls
rating100 rating100
organized organized
o_counter o_counter

View File

@@ -3,7 +3,7 @@ fragment ImageData on Image {
title title
rating100 rating100
date date
url urls
organized organized
o_counter o_counter
created_at created_at

View File

@@ -6,7 +6,8 @@ type Image {
rating: Int @deprecated(reason: "Use 1-100 range with rating100") rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100 # rating expressed as 1-100
rating100: Int rating100: Int
url: String url: String @deprecated(reason: "Use urls")
urls: [String!]!
date: String date: String
o_counter: Int o_counter: Int
organized: Boolean! organized: Boolean!
@@ -48,7 +49,8 @@ input ImageUpdateInput {
# rating expressed as 1-100 # rating expressed as 1-100
rating100: Int rating100: Int
organized: Boolean organized: Boolean
url: String url: String @deprecated(reason: "Use urls")
urls: [String!]
date: String date: String
studio_id: ID studio_id: ID
@@ -68,7 +70,8 @@ input BulkImageUpdateInput {
# rating expressed as 1-100 # rating expressed as 1-100
rating100: Int rating100: Int
organized: Boolean organized: Boolean
url: String url: String @deprecated(reason: "Use urls")
urls: BulkUpdateStrings
date: String date: String
studio_id: ID studio_id: ID

View File

@@ -41,7 +41,7 @@ type Scene {
details: String details: String
director: String director: String
url: String @deprecated(reason: "Use urls") url: String @deprecated(reason: "Use urls")
urls: [String!] urls: [String!]!
date: String date: String
# rating expressed as 1-5 # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100") rating: Int @deprecated(reason: "Use 1-100 range with rating100")

View File

@@ -307,6 +307,46 @@ func (t changesetTranslator) updateIdsBulk(value *BulkUpdateIds, field string) (
}, nil }, nil
} }
func (t changesetTranslator) optionalURLs(value []string, legacyValue *string) *models.UpdateStrings {
const (
legacyField = "url"
field = "urls"
)
// prefer urls over url
if t.hasField(field) {
return t.updateStrings(value, field)
} else if t.hasField(legacyField) {
var valueSlice []string
if legacyValue != nil {
valueSlice = []string{*legacyValue}
}
return t.updateStrings(valueSlice, legacyField)
}
return nil
}
func (t changesetTranslator) optionalURLsBulk(value *BulkUpdateStrings, legacyValue *string) *models.UpdateStrings {
const (
legacyField = "url"
field = "urls"
)
// prefer urls over url
if t.hasField("urls") {
return t.updateStringsBulk(value, field)
} else if t.hasField(legacyField) {
var valueSlice []string
if legacyValue != nil {
valueSlice = []string{*legacyValue}
}
return t.updateStrings(valueSlice, legacyField)
}
return nil
}
func (t changesetTranslator) updateStrings(value []string, field string) *models.UpdateStrings { func (t changesetTranslator) updateStrings(value []string, field string) *models.UpdateStrings {
if !t.hasField(field) { if !t.hasField(field) {
return nil return nil

View File

@@ -198,3 +198,32 @@ func (r *imageResolver) Performers(ctx context.Context, obj *models.Image) (ret
ret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List()) ret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List())
return ret, firstError(errs) return ret, firstError(errs)
} }
func (r *imageResolver) URL(ctx context.Context, obj *models.Image) (*string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Image)
}); err != nil {
return nil, err
}
}
urls := obj.URLs.List()
if len(urls) == 0 {
return nil, nil
}
return &urls[0], nil
}
func (r *imageResolver) Urls(ctx context.Context, obj *models.Image) ([]string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Image)
}); err != nil {
return nil, err
}
}
return obj.URLs.List(), nil
}

View File

@@ -108,7 +108,6 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
updatedImage.Title = translator.optionalString(input.Title, "title") updatedImage.Title = translator.optionalString(input.Title, "title")
updatedImage.Rating = translator.optionalRatingConversion(input.Rating, input.Rating100) updatedImage.Rating = translator.optionalRatingConversion(input.Rating, input.Rating100)
updatedImage.URL = translator.optionalString(input.URL, "url")
updatedImage.Organized = translator.optionalBool(input.Organized, "organized") updatedImage.Organized = translator.optionalBool(input.Organized, "organized")
updatedImage.Date, err = translator.optionalDate(input.Date, "date") updatedImage.Date, err = translator.optionalDate(input.Date, "date")
@@ -120,6 +119,8 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
} }
updatedImage.URLs = translator.optionalURLs(input.Urls, input.URL)
updatedImage.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID) updatedImage.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID)
if err != nil { if err != nil {
return nil, fmt.Errorf("converting primary file id: %w", err) return nil, fmt.Errorf("converting primary file id: %w", err)
@@ -203,7 +204,6 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU
updatedImage.Title = translator.optionalString(input.Title, "title") updatedImage.Title = translator.optionalString(input.Title, "title")
updatedImage.Rating = translator.optionalRatingConversion(input.Rating, input.Rating100) updatedImage.Rating = translator.optionalRatingConversion(input.Rating, input.Rating100)
updatedImage.URL = translator.optionalString(input.URL, "url")
updatedImage.Organized = translator.optionalBool(input.Organized, "organized") updatedImage.Organized = translator.optionalBool(input.Organized, "organized")
updatedImage.Date, err = translator.optionalDate(input.Date, "date") updatedImage.Date, err = translator.optionalDate(input.Date, "date")
@@ -215,6 +215,8 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
} }
updatedImage.URLs = translator.optionalURLsBulk(input.Urls, input.URL)
updatedImage.GalleryIDs, err = translator.updateIdsBulk(input.GalleryIds, "gallery_ids") updatedImage.GalleryIDs, err = translator.updateIdsBulk(input.GalleryIds, "gallery_ids")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting gallery ids: %w", err) return nil, fmt.Errorf("converting gallery ids: %w", err)

View File

@@ -186,16 +186,7 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
} }
// prefer urls over url updatedScene.URLs = translator.optionalURLs(input.Urls, input.URL)
if translator.hasField("urls") {
updatedScene.URLs = translator.updateStrings(input.Urls, "urls")
} else if translator.hasField("url") {
var urls []string
if input.URL != nil {
urls = []string{*input.URL}
}
updatedScene.URLs = translator.updateStrings(urls, "url")
}
updatedScene.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID) updatedScene.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID)
if err != nil { if err != nil {
@@ -342,16 +333,7 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
} }
// prefer urls over url updatedScene.URLs = translator.optionalURLsBulk(input.Urls, input.URL)
if translator.hasField("urls") {
updatedScene.URLs = translator.updateStringsBulk(input.Urls, "urls")
} else if translator.hasField("url") {
var urls []string
if input.URL != nil {
urls = []string{*input.URL}
}
updatedScene.URLs = translator.updateStrings(urls, "url")
}
updatedScene.PerformerIDs, err = translator.updateIdsBulk(input.PerformerIds, "performer_ids") updatedScene.PerformerIDs, err = translator.updateIdsBulk(input.PerformerIds, "performer_ids")
if err != nil { if err != nil {

View File

@@ -647,6 +647,11 @@ func exportImage(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models
continue continue
} }
if err := s.LoadURLs(ctx, repo.Image); err != nil {
logger.Errorf("[images] <%s> error getting image urls: %s", imageHash, err.Error())
continue
}
newImageJSON := image.ToBasicJSON(s) newImageJSON := image.ToBasicJSON(s)
// export files // export files

View File

@@ -14,7 +14,7 @@ import (
func ToBasicJSON(image *models.Image) *jsonschema.Image { func ToBasicJSON(image *models.Image) *jsonschema.Image {
newImageJSON := jsonschema.Image{ newImageJSON := jsonschema.Image{
Title: image.Title, Title: image.Title,
URL: image.URL, URLs: image.URLs.List(),
CreatedAt: json.JSONTime{Time: image.CreatedAt}, CreatedAt: json.JSONTime{Time: image.CreatedAt},
UpdatedAt: json.JSONTime{Time: image.UpdatedAt}, UpdatedAt: json.JSONTime{Time: image.UpdatedAt},
} }
@@ -37,19 +37,6 @@ func ToBasicJSON(image *models.Image) *jsonschema.Image {
return &newImageJSON return &newImageJSON
} }
// func getImageFileJSON(image *models.Image) *jsonschema.ImageFile {
// ret := &jsonschema.ImageFile{}
// f := image.PrimaryFile()
// ret.ModTime = json.JSONTime{Time: f.ModTime}
// ret.Size = f.Size
// ret.Width = f.Width
// ret.Height = f.Height
// return ret
// }
// GetStudioName returns the name of the provided image's studio. It returns an // GetStudioName returns the name of the provided image's studio. It returns an
// empty string if there is no studio assigned to the image. // empty string if there is no studio assigned to the image.
func GetStudioName(ctx context.Context, reader models.StudioGetter, image *models.Image) (string, error) { func GetStudioName(ctx context.Context, reader models.StudioGetter, image *models.Image) (string, error) {

View File

@@ -53,7 +53,7 @@ func createFullImage(id int) models.Image {
OCounter: ocounter, OCounter: ocounter,
Rating: &rating, Rating: &rating,
Date: &dateObj, Date: &dateObj,
URL: url, URLs: models.NewRelatedStrings([]string{url}),
Organized: organized, Organized: organized,
CreatedAt: createTime, CreatedAt: createTime,
UpdatedAt: updateTime, UpdatedAt: updateTime,
@@ -66,7 +66,7 @@ func createFullJSONImage() *jsonschema.Image {
OCounter: ocounter, OCounter: ocounter,
Rating: rating, Rating: rating,
Date: date, Date: date,
URL: url, URLs: []string{url},
Organized: organized, Organized: organized,
Files: []string{path}, Files: []string{path},
CreatedAt: json.JSONTime{ CreatedAt: json.JSONTime{

View File

@@ -62,8 +62,6 @@ func (i *Importer) PreImport(ctx context.Context) error {
func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image { func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image {
newImage := models.Image{ newImage := models.Image{
// Checksum: imageJSON.Checksum,
// Path: i.Path,
PerformerIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}),
TagIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}),
GalleryIDs: models.NewRelatedIDs([]int{}), GalleryIDs: models.NewRelatedIDs([]int{}),
@@ -81,9 +79,12 @@ func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image {
if imageJSON.Rating != 0 { if imageJSON.Rating != 0 {
newImage.Rating = &imageJSON.Rating newImage.Rating = &imageJSON.Rating
} }
if imageJSON.URL != "" { if len(imageJSON.URLs) > 0 {
newImage.URL = imageJSON.URL newImage.URLs = models.NewRelatedStrings(imageJSON.URLs)
} else if imageJSON.URL != "" {
newImage.URLs = models.NewRelatedStrings([]string{imageJSON.URL})
} }
if imageJSON.Date != "" { if imageJSON.Date != "" {
d, err := models.ParseDate(imageJSON.Date) d, err := models.ParseDate(imageJSON.Date)
if err == nil { if err == nil {

View File

@@ -10,10 +10,14 @@ import (
) )
type Image struct { type Image struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Studio string `json:"studio,omitempty"` Studio string `json:"studio,omitempty"`
Rating int `json:"rating,omitempty"` Rating int `json:"rating,omitempty"`
URL string `json:"url,omitempty"`
// deprecated - for import only
URL string `json:"url,omitempty"`
URLs []string `json:"urls,omitempty"`
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Organized bool `json:"organized,omitempty"` Organized bool `json:"organized,omitempty"`
OCounter int `json:"o_counter,omitempty"` OCounter int `json:"o_counter,omitempty"`

View File

@@ -42,8 +42,10 @@ type Scene struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
Studio string `json:"studio,omitempty"` Studio string `json:"studio,omitempty"`
// deprecated - for import only // deprecated - for import only
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
URLs []string `json:"urls,omitempty"` URLs []string `json:"urls,omitempty"`
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Rating int `json:"rating,omitempty"` Rating int `json:"rating,omitempty"`

View File

@@ -462,6 +462,29 @@ func (_m *ImageReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]in
return r0, r1 return r0, r1
} }
// GetURLs provides a mock function with given fields: ctx, relatedID
func (_m *ImageReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) {
ret := _m.Called(ctx, relatedID)
var r0 []string
if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {
r0 = rf(ctx, relatedID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, relatedID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// IncrementOCounter provides a mock function with given fields: ctx, id // IncrementOCounter provides a mock function with given fields: ctx, id
func (_m *ImageReaderWriter) IncrementOCounter(ctx context.Context, id int) (int, error) { func (_m *ImageReaderWriter) IncrementOCounter(ctx context.Context, id int) (int, error) {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)

View File

@@ -13,12 +13,12 @@ type Image struct {
Title string `json:"title"` Title string `json:"title"`
// Rating expressed in 1-100 scale // Rating expressed in 1-100 scale
Rating *int `json:"rating"` Rating *int `json:"rating"`
Organized bool `json:"organized"` Organized bool `json:"organized"`
OCounter int `json:"o_counter"` OCounter int `json:"o_counter"`
StudioID *int `json:"studio_id"` StudioID *int `json:"studio_id"`
URL string `json:"url"` URLs RelatedStrings `json:"urls"`
Date *Date `json:"date"` Date *Date `json:"date"`
// transient - not persisted // transient - not persisted
Files RelatedFiles Files RelatedFiles
@@ -48,7 +48,7 @@ type ImagePartial struct {
Title OptionalString Title OptionalString
// Rating expressed in 1-100 scale // Rating expressed in 1-100 scale
Rating OptionalInt Rating OptionalInt
URL OptionalString URLs *UpdateStrings
Date OptionalDate Date OptionalDate
Organized OptionalBool Organized OptionalBool
OCounter OptionalInt OCounter OptionalInt
@@ -69,6 +69,12 @@ func NewImagePartial() ImagePartial {
} }
} }
func (i *Image) LoadURLs(ctx context.Context, l URLLoader) error {
return i.URLs.load(func() ([]string, error) {
return l.GetURLs(ctx, i.ID)
})
}
func (i *Image) LoadFiles(ctx context.Context, l FileLoader) error { func (i *Image) LoadFiles(ctx context.Context, l FileLoader) error {
return i.Files.load(func() ([]File, error) { return i.Files.load(func() ([]File, error) {
return l.GetFiles(ctx, i.ID) return l.GetFiles(ctx, i.ID)

View File

@@ -63,6 +63,7 @@ type ImageReader interface {
ImageQueryer ImageQueryer
ImageCounter ImageCounter
URLLoader
FileIDLoader FileIDLoader
GalleryIDLoader GalleryIDLoader
PerformerIDLoader PerformerIDLoader

View File

@@ -368,7 +368,6 @@ func (db *Anonymiser) anonymiseImages(ctx context.Context) error {
query := dialect.From(table).Select( query := dialect.From(table).Select(
table.Col(idColumn), table.Col(idColumn),
table.Col("title"), table.Col("title"),
table.Col("url"),
).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)
gotSome = false gotSome = false
@@ -378,20 +377,17 @@ func (db *Anonymiser) anonymiseImages(ctx context.Context) error {
var ( var (
id int id int
title sql.NullString title sql.NullString
url sql.NullString
) )
if err := rows.Scan( if err := rows.Scan(
&id, &id,
&title, &title,
&url,
); err != nil { ); err != nil {
return err return err
} }
set := goqu.Record{} set := goqu.Record{}
db.obfuscateNullString(set, "title", title) db.obfuscateNullString(set, "title", title)
db.obfuscateNullString(set, "url", url)
if len(set) > 0 { if len(set) > 0 {
stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id))
@@ -416,6 +412,10 @@ func (db *Anonymiser) anonymiseImages(ctx context.Context) error {
} }
} }
if err := db.anonymiseURLs(ctx, goqu.T(imagesURLsTable), "image_id"); err != nil {
return err
}
return nil return nil
} }

View File

@@ -33,7 +33,7 @@ const (
dbConnTimeout = 30 dbConnTimeout = 30
) )
var appSchemaVersion uint = 49 var appSchemaVersion uint = 50
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View File

@@ -24,27 +24,27 @@ const (
performersImagesTable = "performers_images" performersImagesTable = "performers_images"
imagesTagsTable = "images_tags" imagesTagsTable = "images_tags"
imagesFilesTable = "images_files" imagesFilesTable = "images_files"
imagesURLsTable = "image_urls"
imageURLColumn = "url"
) )
type imageRow struct { type imageRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"` Title zero.String `db:"title"`
// expressed as 1-100 // expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
URL zero.String `db:"url"` Date NullDate `db:"date"`
Date NullDate `db:"date"` Organized bool `db:"organized"`
Organized bool `db:"organized"` OCounter int `db:"o_counter"`
OCounter int `db:"o_counter"` StudioID null.Int `db:"studio_id,omitempty"`
StudioID null.Int `db:"studio_id,omitempty"` CreatedAt Timestamp `db:"created_at"`
CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"`
UpdatedAt Timestamp `db:"updated_at"`
} }
func (r *imageRow) fromImage(i models.Image) { func (r *imageRow) fromImage(i models.Image) {
r.ID = i.ID r.ID = i.ID
r.Title = zero.StringFrom(i.Title) r.Title = zero.StringFrom(i.Title)
r.Rating = intFromPtr(i.Rating) r.Rating = intFromPtr(i.Rating)
r.URL = zero.StringFrom(i.URL)
r.Date = NullDateFromDatePtr(i.Date) r.Date = NullDateFromDatePtr(i.Date)
r.Organized = i.Organized r.Organized = i.Organized
r.OCounter = i.OCounter r.OCounter = i.OCounter
@@ -66,7 +66,6 @@ func (r *imageQueryRow) resolve() *models.Image {
ID: r.ID, ID: r.ID,
Title: r.Title.String, Title: r.Title.String,
Rating: nullIntPtr(r.Rating), Rating: nullIntPtr(r.Rating),
URL: r.URL.String,
Date: r.Date.DatePtr(), Date: r.Date.DatePtr(),
Organized: r.Organized, Organized: r.Organized,
OCounter: r.OCounter, OCounter: r.OCounter,
@@ -93,7 +92,6 @@ type imageRowRecord struct {
func (r *imageRowRecord) fromPartial(i models.ImagePartial) { func (r *imageRowRecord) fromPartial(i models.ImagePartial) {
r.setNullString("title", i.Title) r.setNullString("title", i.Title)
r.setNullInt("rating", i.Rating) r.setNullInt("rating", i.Rating)
r.setNullString("url", i.URL)
r.setNullDate("date", i.Date) r.setNullDate("date", i.Date)
r.setBool("organized", i.Organized) r.setBool("organized", i.Organized)
r.setInt("o_counter", i.OCounter) r.setInt("o_counter", i.OCounter)
@@ -176,6 +174,13 @@ func (qb *ImageStore) Create(ctx context.Context, newObject *models.Image, fileI
} }
} }
if newObject.URLs.Loaded() {
const startPos = 0
if err := imagesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil {
return err
}
}
if newObject.PerformerIDs.Loaded() { if newObject.PerformerIDs.Loaded() {
if err := imagesPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil { if err := imagesPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil {
return err return err
@@ -223,6 +228,12 @@ func (qb *ImageStore) UpdatePartial(ctx context.Context, id int, partial models.
return nil, err return nil, err
} }
} }
if partial.URLs != nil {
if err := imagesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {
return nil, err
}
}
if partial.PerformerIDs != nil { if partial.PerformerIDs != nil {
if err := imagesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { if err := imagesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil {
return nil, err return nil, err
@@ -251,6 +262,12 @@ func (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) e
return err return err
} }
if updatedObject.URLs.Loaded() {
if err := imagesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil {
return err
}
}
if updatedObject.PerformerIDs.Loaded() { if updatedObject.PerformerIDs.Loaded() {
if err := imagesPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil { if err := imagesPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil {
return err return err
@@ -664,7 +681,7 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF
query.handleCriterion(ctx, intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil)) query.handleCriterion(ctx, intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil))
query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized", nil)) query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized", nil))
query.handleCriterion(ctx, dateCriterionHandler(imageFilter.Date, "images.date")) query.handleCriterion(ctx, dateCriterionHandler(imageFilter.Date, "images.date"))
query.handleCriterion(ctx, stringCriterionHandler(imageFilter.URL, "images.url")) query.handleCriterion(ctx, imageURLsCriterionHandler(imageFilter.URL))
query.handleCriterion(ctx, resolutionCriterionHandler(imageFilter.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable)) query.handleCriterion(ctx, resolutionCriterionHandler(imageFilter.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable))
query.handleCriterion(ctx, imageIsMissingCriterionHandler(qb, imageFilter.IsMissing)) query.handleCriterion(ctx, imageIsMissingCriterionHandler(qb, imageFilter.IsMissing))
@@ -855,6 +872,18 @@ func imageIsMissingCriterionHandler(qb *ImageStore, isMissing *string) criterion
} }
} }
func imageURLsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{
joinTable: imagesURLsTable,
stringColumn: imageURLColumn,
addJoinTable: func(f *filterBuilder) {
imagesURLsTableMgr.join(f, "", "images.id")
},
}
return h.handler(url)
}
func (qb *ImageStore) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { func (qb *ImageStore) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder {
return multiCriterionHandlerBuilder{ return multiCriterionHandlerBuilder{
primaryTable: imageTable, primaryTable: imageTable,
@@ -1097,3 +1126,7 @@ func (qb *ImageStore) UpdateTags(ctx context.Context, imageID int, tagIDs []int)
// Delete the existing joins and then create new ones // Delete the existing joins and then create new ones
return qb.tagsRepository().replace(ctx, imageID, tagIDs) return qb.tagsRepository().replace(ctx, imageID, tagIDs)
} }
func (qb *ImageStore) GetURLs(ctx context.Context, imageID int) ([]string, error) {
return imagesURLsTableMgr.get(ctx, imageID)
}

View File

@@ -15,6 +15,11 @@ import (
) )
func loadImageRelationships(ctx context.Context, expected models.Image, actual *models.Image) error { func loadImageRelationships(ctx context.Context, expected models.Image, actual *models.Image) error {
if expected.URLs.Loaded() {
if err := actual.LoadURLs(ctx, db.Image); err != nil {
return err
}
}
if expected.GalleryIDs.Loaded() { if expected.GalleryIDs.Loaded() {
if err := actual.LoadGalleryIDs(ctx, db.Image); err != nil { if err := actual.LoadGalleryIDs(ctx, db.Image); err != nil {
return err return err
@@ -74,7 +79,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) {
Title: title, Title: title,
Rating: &rating, Rating: &rating,
Date: &date, Date: &date,
URL: url, URLs: models.NewRelatedStrings([]string{url}),
Organized: true, Organized: true,
OCounter: ocounter, OCounter: ocounter,
StudioID: &studioIDs[studioIdxWithImage], StudioID: &studioIDs[studioIdxWithImage],
@@ -92,7 +97,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) {
Title: title, Title: title,
Rating: &rating, Rating: &rating,
Date: &date, Date: &date,
URL: url, URLs: models.NewRelatedStrings([]string{url}),
Organized: true, Organized: true,
OCounter: ocounter, OCounter: ocounter,
StudioID: &studioIDs[studioIdxWithImage], StudioID: &studioIDs[studioIdxWithImage],
@@ -229,7 +234,7 @@ func Test_imageQueryBuilder_Update(t *testing.T) {
ID: imageIDs[imageIdxWithGallery], ID: imageIDs[imageIdxWithGallery],
Title: title, Title: title,
Rating: &rating, Rating: &rating,
URL: url, URLs: models.NewRelatedStrings([]string{url}),
Date: &date, Date: &date,
Organized: true, Organized: true,
OCounter: ocounter, OCounter: ocounter,
@@ -378,7 +383,7 @@ func clearImagePartial() models.ImagePartial {
return models.ImagePartial{ return models.ImagePartial{
Title: models.OptionalString{Set: true, Null: true}, Title: models.OptionalString{Set: true, Null: true},
Rating: models.OptionalInt{Set: true, Null: true}, Rating: models.OptionalInt{Set: true, Null: true},
URL: models.OptionalString{Set: true, Null: true}, URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},
Date: models.OptionalDate{Set: true, Null: true}, Date: models.OptionalDate{Set: true, Null: true},
StudioID: models.OptionalInt{Set: true, Null: true}, StudioID: models.OptionalInt{Set: true, Null: true},
GalleryIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, GalleryIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},
@@ -409,9 +414,12 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
"full", "full",
imageIDs[imageIdx1WithGallery], imageIDs[imageIdx1WithGallery],
models.ImagePartial{ models.ImagePartial{
Title: models.NewOptionalString(title), Title: models.NewOptionalString(title),
Rating: models.NewOptionalInt(rating), Rating: models.NewOptionalInt(rating),
URL: models.NewOptionalString(url), URLs: &models.UpdateStrings{
Values: []string{url},
Mode: models.RelationshipUpdateModeSet,
},
Date: models.NewOptionalDate(date), Date: models.NewOptionalDate(date),
Organized: models.NewOptionalBool(true), Organized: models.NewOptionalBool(true),
OCounter: models.NewOptionalInt(ocounter), OCounter: models.NewOptionalInt(ocounter),
@@ -435,7 +443,7 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
ID: imageIDs[imageIdx1WithGallery], ID: imageIDs[imageIdx1WithGallery],
Title: title, Title: title,
Rating: &rating, Rating: &rating,
URL: url, URLs: models.NewRelatedStrings([]string{url}),
Date: &date, Date: &date,
Organized: true, Organized: true,
OCounter: ocounter, OCounter: ocounter,
@@ -1519,6 +1527,67 @@ func imageQueryQ(ctx context.Context, t *testing.T, sqb models.ImageReader, q st
assert.Len(t, images, totalImages) assert.Len(t, images, totalImages)
} }
func verifyImageQuery(t *testing.T, filter models.ImageFilterType, verifyFn func(ctx context.Context, s *models.Image)) {
t.Helper()
withTxn(func(ctx context.Context) error {
t.Helper()
sqb := db.Image
images := queryImages(ctx, t, sqb, &filter, nil)
// assume it should find at least one
assert.Greater(t, len(images), 0)
for _, image := range images {
verifyFn(ctx, image)
}
return nil
})
}
func TestImageQueryURL(t *testing.T) {
const imageIdx = 1
imageURL := getImageStringValue(imageIdx, urlField)
urlCriterion := models.StringCriterionInput{
Value: imageURL,
Modifier: models.CriterionModifierEquals,
}
filter := models.ImageFilterType{
URL: &urlCriterion,
}
verifyFn := func(ctx context.Context, o *models.Image) {
t.Helper()
if err := o.LoadURLs(ctx, db.Image); err != nil {
t.Errorf("Error loading scene URLs: %v", err)
}
urls := o.URLs.List()
var url string
if len(urls) > 0 {
url = urls[0]
}
verifyString(t, url, urlCriterion)
}
verifyImageQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierNotEquals
verifyImageQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierMatchesRegex
urlCriterion.Value = "image_.*1_URL"
verifyImageQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex
verifyImageQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierIsNull
urlCriterion.Value = ""
verifyImageQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierNotNull
verifyImageQuery(t, filter, verifyFn)
}
func TestImageQueryPath(t *testing.T) { func TestImageQueryPath(t *testing.T) {
const imageIdx = 1 const imageIdx = 1
imagePath := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx)) imagePath := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx))

View File

@@ -0,0 +1,70 @@
PRAGMA foreign_keys=OFF;
CREATE TABLE `image_urls` (
`image_id` integer NOT NULL,
`position` integer NOT NULL,
`url` varchar(255) NOT NULL,
foreign key(`image_id`) references `images`(`id`) on delete CASCADE,
PRIMARY KEY(`image_id`, `position`, `url`)
);
CREATE INDEX `image_urls_url` on `image_urls` (`url`);
-- drop url
CREATE TABLE "images_new" (
`id` integer not null primary key autoincrement,
`title` varchar(255),
`rating` tinyint,
`studio_id` integer,
`o_counter` tinyint not null default 0,
`organized` boolean not null default '0',
`created_at` datetime not null,
`updated_at` datetime not null,
`date` date,
foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL
);
INSERT INTO `images_new`
(
`id`,
`title`,
`rating`,
`studio_id`,
`o_counter`,
`organized`,
`created_at`,
`updated_at`,
`date`
)
SELECT
`id`,
`title`,
`rating`,
`studio_id`,
`o_counter`,
`organized`,
`created_at`,
`updated_at`,
`date`
FROM `images`;
INSERT INTO `image_urls`
(
`image_id`,
`position`,
`url`
)
SELECT
`id`,
'0',
`url`
FROM `images`
WHERE `images`.`url` IS NOT NULL AND `images`.`url` != '';
DROP INDEX `index_images_on_studio_id`;
DROP TABLE `images`;
ALTER TABLE `images_new` rename to `images`;
CREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`);
PRAGMA foreign_keys=ON;

View File

@@ -1113,6 +1113,19 @@ func getImageStringValue(index int, field string) string {
return fmt.Sprintf("image_%04d_%s", index, field) return fmt.Sprintf("image_%04d_%s", index, field)
} }
func getImageNullStringPtr(index int, field string) *string {
return getStringPtrFromNullString(getPrefixedNullStringValue("image", index, field))
}
func getImageEmptyString(index int, field string) string {
v := getImageNullStringPtr(index, field)
if v == nil {
return ""
}
return *v
}
func getImageBasename(index int) string { func getImageBasename(index int) string {
return getImageStringValue(index, pathField) return getImageStringValue(index, pathField)
} }
@@ -1148,10 +1161,12 @@ func makeImage(i int) *models.Image {
tids := indexesToIDs(tagIDs, imageTags[i]) tids := indexesToIDs(tagIDs, imageTags[i])
return &models.Image{ return &models.Image{
Title: title, Title: title,
Rating: getIntPtr(getRating(i)), Rating: getIntPtr(getRating(i)),
Date: getObjectDate(i), Date: getObjectDate(i),
URL: getImageStringValue(i, urlField), URLs: models.NewRelatedStrings([]string{
getImageEmptyString(i, urlField),
}),
OCounter: getOCounter(i), OCounter: getOCounter(i),
StudioID: studioID, StudioID: studioID,
GalleryIDs: models.NewRelatedIDs(gids), GalleryIDs: models.NewRelatedIDs(gids),

View File

@@ -13,6 +13,7 @@ var (
imagesTagsJoinTable = goqu.T(imagesTagsTable) imagesTagsJoinTable = goqu.T(imagesTagsTable)
performersImagesJoinTable = goqu.T(performersImagesTable) performersImagesJoinTable = goqu.T(performersImagesTable)
imagesFilesJoinTable = goqu.T(imagesFilesTable) imagesFilesJoinTable = goqu.T(imagesFilesTable)
imagesURLsJoinTable = goqu.T(imagesURLsTable)
galleriesFilesJoinTable = goqu.T(galleriesFilesTable) galleriesFilesJoinTable = goqu.T(galleriesFilesTable)
galleriesTagsJoinTable = goqu.T(galleriesTagsTable) galleriesTagsJoinTable = goqu.T(galleriesTagsTable)
@@ -70,6 +71,14 @@ var (
}, },
fkColumn: performersImagesJoinTable.Col(performerIDColumn), fkColumn: performersImagesJoinTable.Col(performerIDColumn),
} }
imagesURLsTableMgr = &orderedValueTable[string]{
table: table{
table: imagesURLsJoinTable,
idColumn: imagesURLsJoinTable.Col(imageIDColumn),
},
valueColumn: imagesURLsJoinTable.Col(imageURLColumn),
}
) )
var ( var (

View File

@@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql";
import * as yup from "yup"; import * as yup from "yup";
import { TagSelect, StudioSelect } from "src/components/Shared/Select"; import { TagSelect, StudioSelect } from "src/components/Shared/Select";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { URLField } from "src/components/Shared/URLField"; import { URLListInput } from "src/components/Shared/URLField";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import FormUtils from "src/utils/form"; import FormUtils from "src/utils/form";
import { useFormik } from "formik"; import { useFormik } from "formik";
@@ -16,6 +16,7 @@ import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual"; import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput"; import { DateInput } from "src/components/Shared/DateInput";
import { yupDateString, yupUniqueStringList } from "src/utils/yup";
import { import {
Performer, Performer,
PerformerSelect, PerformerSelect,
@@ -46,20 +47,8 @@ export const ImageEditPanel: React.FC<IProps> = ({
const schema = yup.object({ const schema = yup.object({
title: yup.string().ensure(), title: yup.string().ensure(),
url: yup.string().ensure(), urls: yupUniqueStringList("urls"),
date: yup date: yupDateString(intl),
.string()
.ensure()
.test({
name: "date",
test: (value) => {
if (!value) return true;
if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
if (Number.isNaN(Date.parse(value))) return false;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
rating100: yup.number().nullable().defined(), rating100: yup.number().nullable().defined(),
studio_id: yup.string().required().nullable(), studio_id: yup.string().required().nullable(),
performer_ids: yup.array(yup.string().required()).defined(), performer_ids: yup.array(yup.string().required()).defined(),
@@ -68,7 +57,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const initialValues = { const initialValues = {
title: image.title ?? "", title: image.title ?? "",
url: image?.url ?? "", urls: image?.urls ?? [],
date: image?.date ?? "", date: image?.date ?? "",
rating100: image.rating100 ?? null, rating100: image.rating100 ?? null,
studio_id: image.studio?.id ?? null, studio_id: image.studio?.id ?? null,
@@ -162,6 +151,14 @@ export const ImageEditPanel: React.FC<IProps> = ({
if (isLoading) return <LoadingIndicator />; if (isLoading) return <LoadingIndicator />;
const urlsErrors = Array.isArray(formik.errors.urls)
? formik.errors.urls[0]
: formik.errors.urls;
const urlsErrorMsg = urlsErrors
? intl.formatMessage({ id: "validation.urls_must_be_unique" })
: undefined;
const urlsErrorIdx = urlsErrors?.split(" ").map((e) => parseInt(e));
return ( return (
<div id="image-edit-details"> <div id="image-edit-details">
<Prompt <Prompt
@@ -192,20 +189,18 @@ export const ImageEditPanel: React.FC<IProps> = ({
<div className="form-container row px-3"> <div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12"> <div className="col-12 col-lg-6 col-xl-12">
{renderTextField("title", intl.formatMessage({ id: "title" }))} {renderTextField("title", intl.formatMessage({ id: "title" }))}
<Form.Group controlId="url" as={Row}> <Form.Group controlId="urls" as={Row}>
<Col xs={3} className="pr-0 url-label"> <Col xs={3} className="pr-0 url-label">
<Form.Label className="col-form-label"> <Form.Label className="col-form-label">
<FormattedMessage id="url" /> <FormattedMessage id="urls" />
</Form.Label> </Form.Label>
</Col> </Col>
<Col xs={9}> <Col xs={9}>
<URLField <URLListInput
{...formik.getFieldProps("url")} value={formik.values.urls ?? []}
onScrapeClick={() => {}} setValue={(value) => formik.setFieldValue("urls", value)}
urlScrapable={() => { errors={urlsErrorMsg}
return false; errorIdx={urlsErrorIdx}
}}
isInvalid={!!formik.getFieldMeta("url").error}
/> />
</Col> </Col>
</Form.Group> </Form.Group>

View File

@@ -7,7 +7,7 @@ import * as GQL from "src/core/generated-graphql";
import { mutateImageSetPrimaryFile } from "src/core/StashService"; import { mutateImageSetPrimaryFile } from "src/core/StashService";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { TextField, URLField } from "src/utils/field"; import { TextField, URLField, URLsField } from "src/utils/field";
interface IFileInfoPanelProps { interface IFileInfoPanelProps {
file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment; file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment;
@@ -120,20 +120,11 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
if (props.image.visual_files.length === 1) { if (props.image.visual_files.length === 1) {
return ( return (
<> <>
<FileInfoPanel file={props.image.visual_files[0]} /> <dl className="container image-file-info details-list">
<URLsField id="urls" urls={props.image.urls} truncate />
</dl>
{props.image.url ? ( <FileInfoPanel file={props.image.visual_files[0]} />
<dl className="container image-file-info details-list">
<URLField
id="media_info.downloaded_from"
url={TextUtils.sanitiseURL(props.image.url)}
value={TextUtils.domainFromURL(props.image.url)}
truncate
/>
</dl>
) : (
""
)}
</> </>
); );
} }

View File

@@ -50,6 +50,7 @@ import { useRatingKeybinds } from "src/hooks/keybinds";
import { lazyComponent } from "src/utils/lazyComponent"; import { lazyComponent } from "src/utils/lazyComponent";
import isEqual from "lodash-es/isEqual"; import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput"; import { DateInput } from "src/components/Shared/DateInput";
import { yupDateString, yupUniqueStringList } from "src/utils/yup";
import { import {
Performer, Performer,
PerformerSelect, PerformerSelect,
@@ -114,38 +115,8 @@ export const SceneEditPanel: React.FC<IProps> = ({
const schema = yup.object({ const schema = yup.object({
title: yup.string().ensure(), title: yup.string().ensure(),
code: yup.string().ensure(), code: yup.string().ensure(),
urls: yup urls: yupUniqueStringList("urls"),
.array(yup.string().required()) date: yupDateString(intl),
.defined()
.test({
name: "unique",
test: (value) => {
const dupes = value
.map((e, i, a) => {
if (a.indexOf(e) !== i) {
return String(i - 1);
} else {
return null;
}
})
.filter((e) => e !== null) as string[];
if (dupes.length === 0) return true;
return new yup.ValidationError(dupes.join(" "), value, "urls");
},
}),
date: yup
.string()
.ensure()
.test({
name: "date",
test: (value) => {
if (!value) return true;
if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
if (Number.isNaN(Date.parse(value))) return false;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
director: yup.string().ensure(), director: yup.string().ensure(),
rating100: yup.number().nullable().defined(), rating100: yup.number().nullable().defined(),
gallery_ids: yup.array(yup.string().required()).defined(), gallery_ids: yup.array(yup.string().required()).defined(),

View File

@@ -50,8 +50,8 @@ export const URLField: React.FC<IProps> = (props: IProps) => {
}; };
interface IURLListProps extends IStringListInputProps { interface IURLListProps extends IStringListInputProps {
onScrapeClick(url: string): void; onScrapeClick?: (url: string) => void;
urlScrapable(url: string): boolean; urlScrapable?: (url: string) => boolean;
} }
export const URLListInput: React.FC<IURLListProps> = ( export const URLListInput: React.FC<IURLListProps> = (
@@ -64,17 +64,23 @@ export const URLListInput: React.FC<IURLListProps> = (
{...listProps} {...listProps}
placeholder={intl.formatMessage({ id: "url" })} placeholder={intl.formatMessage({ id: "url" })}
inputComponent={StringInput} inputComponent={StringInput}
appendComponent={(props) => ( appendComponent={(props) => {
<Button if (!onScrapeClick || !urlScrapable) {
className="scrape-url-button text-input" return <></>;
variant="secondary" }
onClick={() => onScrapeClick(props.value)}
disabled={!props.value || !urlScrapable(props.value)} return (
title={intl.formatMessage({ id: "actions.scrape" })} <Button
> className="scrape-url-button text-input"
<Icon icon={faFileDownload} /> variant="secondary"
</Button> onClick={() => onScrapeClick(props.value)}
)} disabled={!props.value || !urlScrapable(props.value)}
title={intl.formatMessage({ id: "actions.scrape" })}
>
<Icon icon={faFileDownload} />
</Button>
);
}}
/> />
); );
}; };

40
ui/v2.5/src/utils/yup.ts Normal file
View File

@@ -0,0 +1,40 @@
import { IntlShape } from "react-intl";
import * as yup from "yup";
export function yupUniqueStringList(fieldName: string) {
return yup
.array(yup.string().required())
.defined()
.test({
name: "unique",
test: (value) => {
const dupes = value
.map((e, i, a) => {
if (a.indexOf(e) !== i) {
return String(i - 1);
} else {
return null;
}
})
.filter((e) => e !== null) as string[];
if (dupes.length === 0) return true;
return new yup.ValidationError(dupes.join(" "), value, fieldName);
},
});
}
export function yupDateString(intl: IntlShape) {
return yup
.string()
.ensure()
.test({
name: "date",
test: (value) => {
if (!value) return true;
if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
if (Number.isNaN(Date.parse(value))) return false;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
});
}