mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Multiple image URLs (#4000)
* Backend changes - ported from scene impl * Front end changes * Refactor URL mutation code
This commit is contained in:
@@ -2,7 +2,7 @@ fragment SlimImageData on Image {
|
|||||||
id
|
id
|
||||||
title
|
title
|
||||||
date
|
date
|
||||||
url
|
urls
|
||||||
rating100
|
rating100
|
||||||
organized
|
organized
|
||||||
o_counter
|
o_counter
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type ImageReader interface {
|
|||||||
ImageQueryer
|
ImageQueryer
|
||||||
ImageCounter
|
ImageCounter
|
||||||
|
|
||||||
|
URLLoader
|
||||||
FileIDLoader
|
FileIDLoader
|
||||||
GalleryIDLoader
|
GalleryIDLoader
|
||||||
PerformerIDLoader
|
PerformerIDLoader
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
70
pkg/sqlite/migrations/50_image_urls.up.sql
Normal file
70
pkg/sqlite/migrations/50_image_urls.up.sql
Normal 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;
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
40
ui/v2.5/src/utils/yup.ts
Normal 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" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user