mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Add grid view, image to tag (#641)
* Add grid view for tags * Add tag page * Import/export tags * Add tag name uniqueness checks * Fix styling on missing marker previews * Add trace loglevel * Add SQL trace * Add filter options for tags * Add tag sort by options * Add tag page keyboard shortcuts
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
fragment TagData on Tag {
|
fragment TagData on Tag {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
image_path
|
||||||
scene_count
|
scene_count
|
||||||
scene_marker_count
|
scene_marker_count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mutation TagCreate($name: String!) {
|
mutation TagCreate($name: String!, $image: String) {
|
||||||
tagCreate(input: { name: $name }) {
|
tagCreate(input: { name: $name, image: $image }) {
|
||||||
...TagData
|
...TagData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,8 @@ mutation TagDestroy($id: ID!) {
|
|||||||
tagDestroy(input: { id: $id })
|
tagDestroy(input: { id: $id })
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation TagUpdate($id: ID!, $name: String!) {
|
mutation TagUpdate($id: ID!, $name: String!, $image: String) {
|
||||||
tagUpdate(input: { id: $id, name: $name }) {
|
tagUpdate(input: { id: $id, name: $name, image: $image }) {
|
||||||
...TagData
|
...TagData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
query FindTag($id: ID!) {
|
|
||||||
findTag(id: $id) {
|
|
||||||
...TagData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query MarkerStrings($q: String, $sort: String) {
|
query MarkerStrings($q: String, $sort: String) {
|
||||||
markerStrings(q: $q, sort: $sort) {
|
markerStrings(q: $q, sort: $sort) {
|
||||||
id
|
id
|
||||||
|
|||||||
14
graphql/documents/queries/tag.graphql
Normal file
14
graphql/documents/queries/tag.graphql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
query FindTags($filter: FindFilterType, $tag_filter: TagFilterType ) {
|
||||||
|
findTags(filter: $filter, tag_filter: $tag_filter) {
|
||||||
|
count
|
||||||
|
tags {
|
||||||
|
...TagData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query FindTag($id: ID!) {
|
||||||
|
findTag(id: $id) {
|
||||||
|
...TagData
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ type Query {
|
|||||||
findGalleries(gallery_filter: GalleryFilterType, filter: FindFilterType): FindGalleriesResultType!
|
findGalleries(gallery_filter: GalleryFilterType, filter: FindFilterType): FindGalleriesResultType!
|
||||||
|
|
||||||
findTag(id: ID!): Tag
|
findTag(id: ID!): Tag
|
||||||
|
findTags(tag_filter: TagFilterType, filter: FindFilterType): FindTagsResultType!
|
||||||
|
|
||||||
"""Retrieve random scene markers for the wall"""
|
"""Retrieve random scene markers for the wall"""
|
||||||
markerWall(q: String): [SceneMarker!]!
|
markerWall(q: String): [SceneMarker!]!
|
||||||
|
|||||||
@@ -101,6 +101,17 @@ input GalleryFilterType {
|
|||||||
is_missing: String
|
is_missing: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input TagFilterType {
|
||||||
|
"""Filter to only include tags missing this property"""
|
||||||
|
is_missing: String
|
||||||
|
|
||||||
|
"""Filter by number of scenes with this tag"""
|
||||||
|
scene_count: IntCriterionInput
|
||||||
|
|
||||||
|
"""Filter by number of markers with this tag"""
|
||||||
|
marker_count: IntCriterionInput
|
||||||
|
}
|
||||||
|
|
||||||
enum CriterionModifier {
|
enum CriterionModifier {
|
||||||
"""="""
|
"""="""
|
||||||
EQUALS,
|
EQUALS,
|
||||||
|
|||||||
@@ -2,19 +2,31 @@ type Tag {
|
|||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
|
|
||||||
|
image_path: String # Resolver
|
||||||
scene_count: Int # Resolver
|
scene_count: Int # Resolver
|
||||||
scene_marker_count: Int # Resolver
|
scene_marker_count: Int # Resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
input TagCreateInput {
|
input TagCreateInput {
|
||||||
name: String!
|
name: String!
|
||||||
|
|
||||||
|
"""This should be base64 encoded"""
|
||||||
|
image: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input TagUpdateInput {
|
input TagUpdateInput {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
|
|
||||||
|
"""This should be base64 encoded"""
|
||||||
|
image: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input TagDestroyInput {
|
input TagDestroyInput {
|
||||||
id: ID!
|
id: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindTagsResultType {
|
||||||
|
count: Int!
|
||||||
|
tags: [Tag!]!
|
||||||
}
|
}
|
||||||
@@ -11,4 +11,5 @@ const (
|
|||||||
studioKey key = 3
|
studioKey key = 3
|
||||||
movieKey key = 4
|
movieKey key = 4
|
||||||
ContextUser key = 5
|
ContextUser key = 5
|
||||||
|
tagKey key = 6
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/api/urlbuilders"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,3 +24,9 @@ func (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag) (*i
|
|||||||
count, err := qb.CountByTagID(obj.ID)
|
count, err := qb.CountByTagID(obj.ID)
|
||||||
return &count, err
|
return &count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) {
|
||||||
|
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||||
|
imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj.ID).GetTagImageURL()
|
||||||
|
return &imagePath, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/stashapp/stash/pkg/database"
|
"fmt"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/database"
|
||||||
|
"github.com/stashapp/stash/pkg/manager"
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreateInput) (*models.Tag, error) {
|
func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreateInput) (*models.Tag, error) {
|
||||||
@@ -17,15 +21,41 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate
|
|||||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the transaction and save the studio
|
var imageData []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if input.Image != nil {
|
||||||
|
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the transaction and save the tag
|
||||||
tx := database.DB.MustBeginTx(ctx, nil)
|
tx := database.DB.MustBeginTx(ctx, nil)
|
||||||
qb := models.NewTagQueryBuilder()
|
qb := models.NewTagQueryBuilder()
|
||||||
|
|
||||||
|
// ensure name is unique
|
||||||
|
if err := manager.EnsureTagNameUnique(newTag.Name, tx); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
tag, err := qb.Create(newTag, tx)
|
tag, err := qb.Create(newTag, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
tx.Rollback()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update image table
|
||||||
|
if len(imageData) > 0 {
|
||||||
|
if err := qb.UpdateTagImage(tag.ID, imageData, tx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Commit
|
// Commit
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -43,15 +73,54 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
|
|||||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: time.Now()},
|
UpdatedAt: models.SQLiteTimestamp{Timestamp: time.Now()},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var imageData []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if input.Image != nil {
|
||||||
|
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start the transaction and save the tag
|
// Start the transaction and save the tag
|
||||||
tx := database.DB.MustBeginTx(ctx, nil)
|
tx := database.DB.MustBeginTx(ctx, nil)
|
||||||
qb := models.NewTagQueryBuilder()
|
qb := models.NewTagQueryBuilder()
|
||||||
|
|
||||||
|
// ensure name is unique
|
||||||
|
existing, err := qb.Find(tagID, tx)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("Tag with ID %d not found", tagID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.Name != updatedTag.Name {
|
||||||
|
if err := manager.EnsureTagNameUnique(updatedTag.Name, tx); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tag, err := qb.Update(updatedTag, tx)
|
tag, err := qb.Update(updatedTag, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update image table
|
||||||
|
if len(imageData) > 0 {
|
||||||
|
if err := qb.UpdateTagImage(tag.ID, imageData, tx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Commit
|
// Commit
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *queryResolver) FindTag(ctx context.Context, id string) (*models.Tag, error) {
|
func (r *queryResolver) FindTag(ctx context.Context, id string) (*models.Tag, error) {
|
||||||
@@ -12,6 +13,15 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (*models.Tag, er
|
|||||||
return qb.Find(idInt, nil)
|
return qb.Find(idInt, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType) (*models.FindTagsResultType, error) {
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
tags, total := qb.Query(tagFilter, filter)
|
||||||
|
return &models.FindTagsResultType{
|
||||||
|
Count: total,
|
||||||
|
Tags: tags,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *queryResolver) AllTags(ctx context.Context) ([]*models.Tag, error) {
|
func (r *queryResolver) AllTags(ctx context.Context) ([]*models.Tag, error) {
|
||||||
qb := models.NewTagQueryBuilder()
|
qb := models.NewTagQueryBuilder()
|
||||||
return qb.All()
|
return qb.All()
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type studioRoutes struct{}
|
type studioRoutes struct{}
|
||||||
@@ -29,23 +27,7 @@ func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) {
|
|||||||
studio := r.Context().Value(studioKey).(*models.Studio)
|
studio := r.Context().Value(studioKey).(*models.Studio)
|
||||||
qb := models.NewStudioQueryBuilder()
|
qb := models.NewStudioQueryBuilder()
|
||||||
image, _ := qb.GetStudioImage(studio.ID, nil)
|
image, _ := qb.GetStudioImage(studio.ID, nil)
|
||||||
|
utils.ServeImage(image, w, r)
|
||||||
etag := fmt.Sprintf("%x", md5.Sum(image))
|
|
||||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
|
||||||
if strings.Contains(match, etag) {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := http.DetectContentType(image)
|
|
||||||
if contentType == "text/xml; charset=utf-8" || contentType == "text/plain; charset=utf-8" {
|
|
||||||
contentType = "image/svg+xml"
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", contentType)
|
|
||||||
w.Header().Add("Etag", etag)
|
|
||||||
w.Write(image)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func StudioCtx(next http.Handler) http.Handler {
|
func StudioCtx(next http.Handler) http.Handler {
|
||||||
|
|||||||
57
pkg/api/routes_tag.go
Normal file
57
pkg/api/routes_tag.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tagRoutes struct{}
|
||||||
|
|
||||||
|
func (rs tagRoutes) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Route("/{tagId}", func(r chi.Router) {
|
||||||
|
r.Use(TagCtx)
|
||||||
|
r.Get("/image", rs.Image)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tag := r.Context().Value(tagKey).(*models.Tag)
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
image, _ := qb.GetTagImage(tag.ID, nil)
|
||||||
|
|
||||||
|
// use default image if not present
|
||||||
|
if len(image) == 0 {
|
||||||
|
image = models.DefaultTagImage
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.ServeImage(image, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TagCtx(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tagID, err := strconv.Atoi(chi.URLParam(r, "tagId"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(404), 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
tag, err := qb.Find(tagID, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(404), 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), tagKey, tag)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -154,6 +154,7 @@ func Start() {
|
|||||||
r.Mount("/scene", sceneRoutes{}.Routes())
|
r.Mount("/scene", sceneRoutes{}.Routes())
|
||||||
r.Mount("/studio", studioRoutes{}.Routes())
|
r.Mount("/studio", studioRoutes{}.Routes())
|
||||||
r.Mount("/movie", movieRoutes{}.Routes())
|
r.Mount("/movie", movieRoutes{}.Routes())
|
||||||
|
r.Mount("/tag", tagRoutes{}.Routes())
|
||||||
|
|
||||||
r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/css")
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
|||||||
19
pkg/api/urlbuilders/tag.go
Normal file
19
pkg/api/urlbuilders/tag.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package urlbuilders
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
type TagURLBuilder struct {
|
||||||
|
BaseURL string
|
||||||
|
TagID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTagURLBuilder(baseURL string, tagID int) TagURLBuilder {
|
||||||
|
return TagURLBuilder{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
TagID: strconv.Itoa(tagID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b TagURLBuilder) GetTagImageURL() string {
|
||||||
|
return b.BaseURL + "/tag/" + b.TagID + "/image"
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
|
|
||||||
var DB *sqlx.DB
|
var DB *sqlx.DB
|
||||||
var dbPath string
|
var dbPath string
|
||||||
var appSchemaVersion uint = 10
|
var appSchemaVersion uint = 11
|
||||||
var databaseSchemaVersion uint
|
var databaseSchemaVersion uint
|
||||||
|
|
||||||
const sqlite3Driver = "sqlite3ex"
|
const sqlite3Driver = "sqlite3ex"
|
||||||
|
|||||||
7
pkg/database/migrations/11_tag_image.up.sql
Normal file
7
pkg/database/migrations/11_tag_image.up.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE `tags_image` (
|
||||||
|
`tag_id` integer,
|
||||||
|
`image` blob not null,
|
||||||
|
foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX `index_tag_image_on_tag_id` on `tags_image` (`tag_id`);
|
||||||
@@ -65,6 +65,8 @@ func logLevelFromString(level string) logrus.Level {
|
|||||||
ret = logrus.WarnLevel
|
ret = logrus.WarnLevel
|
||||||
} else if level == "Error" {
|
} else if level == "Error" {
|
||||||
ret = logrus.ErrorLevel
|
ret = logrus.ErrorLevel
|
||||||
|
} else if level == "Trace" {
|
||||||
|
ret = logrus.TraceLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
@@ -178,6 +180,15 @@ func Trace(args ...interface{}) {
|
|||||||
logger.Trace(args...)
|
logger.Trace(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Tracef(format string, args ...interface{}) {
|
||||||
|
logger.Tracef(format, args...)
|
||||||
|
l := &LogItem{
|
||||||
|
Type: "trace",
|
||||||
|
Message: fmt.Sprintf(format, args...),
|
||||||
|
}
|
||||||
|
addLogItem(l)
|
||||||
|
}
|
||||||
|
|
||||||
func Debug(args ...interface{}) {
|
func Debug(args ...interface{}) {
|
||||||
logger.Debug(args...)
|
logger.Debug(args...)
|
||||||
l := &LogItem{
|
l := &LogItem{
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ func (jp *jsonUtils) saveStudio(checksum string, studio *jsonschema.Studio) erro
|
|||||||
return jsonschema.SaveStudioFile(instance.Paths.JSON.StudioJSONPath(checksum), studio)
|
return jsonschema.SaveStudioFile(instance.Paths.JSON.StudioJSONPath(checksum), studio)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (jp *jsonUtils) getTag(checksum string) (*jsonschema.Tag, error) {
|
||||||
|
return jsonschema.LoadTagFile(instance.Paths.JSON.TagJSONPath(checksum))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (jp *jsonUtils) saveTag(checksum string, tag *jsonschema.Tag) error {
|
||||||
|
return jsonschema.SaveTagFile(instance.Paths.JSON.TagJSONPath(checksum), tag)
|
||||||
|
}
|
||||||
|
|
||||||
func (jp *jsonUtils) getMovie(checksum string) (*jsonschema.Movie, error) {
|
func (jp *jsonUtils) getMovie(checksum string) (*jsonschema.Movie, error) {
|
||||||
return jsonschema.LoadMovieFile(instance.Paths.JSON.MovieJSONPath(checksum))
|
return jsonschema.LoadMovieFile(instance.Paths.JSON.MovieJSONPath(checksum))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package jsonschema
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/json-iterator/go"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
jsoniter "github.com/json-iterator/go"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NameMapping struct {
|
type NameMapping struct {
|
||||||
@@ -17,6 +18,7 @@ type PathMapping struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Mappings struct {
|
type Mappings struct {
|
||||||
|
Tags []NameMapping `json:"tags"`
|
||||||
Performers []NameMapping `json:"performers"`
|
Performers []NameMapping `json:"performers"`
|
||||||
Studios []NameMapping `json:"studios"`
|
Studios []NameMapping `json:"studios"`
|
||||||
Movies []NameMapping `json:"movies"`
|
Movies []NameMapping `json:"movies"`
|
||||||
|
|||||||
39
pkg/manager/jsonschema/tag.go
Normal file
39
pkg/manager/jsonschema/tag.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package jsonschema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
jsoniter "github.com/json-iterator/go"
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
CreatedAt models.JSONTime `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt models.JSONTime `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadTagFile(filePath string) (*Tag, error) {
|
||||||
|
var tag Tag
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
defer file.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var json = jsoniter.ConfigCompatibleWithStandardLibrary
|
||||||
|
jsonParser := json.NewDecoder(file)
|
||||||
|
err = jsonParser.Decode(&tag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveTagFile(filePath string, tag *Tag) error {
|
||||||
|
if tag == nil {
|
||||||
|
return fmt.Errorf("tag must not be nil")
|
||||||
|
}
|
||||||
|
return marshalToFile(filePath, tag)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package paths
|
package paths
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/manager/config"
|
"github.com/stashapp/stash/pkg/manager/config"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
"path/filepath"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type jsonPaths struct {
|
type jsonPaths struct {
|
||||||
@@ -16,6 +17,7 @@ type jsonPaths struct {
|
|||||||
Scenes string
|
Scenes string
|
||||||
Galleries string
|
Galleries string
|
||||||
Studios string
|
Studios string
|
||||||
|
Tags string
|
||||||
Movies string
|
Movies string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ func newJSONPaths() *jsonPaths {
|
|||||||
jp.Galleries = filepath.Join(config.GetMetadataPath(), "galleries")
|
jp.Galleries = filepath.Join(config.GetMetadataPath(), "galleries")
|
||||||
jp.Studios = filepath.Join(config.GetMetadataPath(), "studios")
|
jp.Studios = filepath.Join(config.GetMetadataPath(), "studios")
|
||||||
jp.Movies = filepath.Join(config.GetMetadataPath(), "movies")
|
jp.Movies = filepath.Join(config.GetMetadataPath(), "movies")
|
||||||
|
jp.Tags = filepath.Join(config.GetMetadataPath(), "tags")
|
||||||
return &jp
|
return &jp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ func EnsureJSONDirs() {
|
|||||||
utils.EnsureDir(jsonPaths.Performers)
|
utils.EnsureDir(jsonPaths.Performers)
|
||||||
utils.EnsureDir(jsonPaths.Studios)
|
utils.EnsureDir(jsonPaths.Studios)
|
||||||
utils.EnsureDir(jsonPaths.Movies)
|
utils.EnsureDir(jsonPaths.Movies)
|
||||||
|
utils.EnsureDir(jsonPaths.Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (jp *jsonPaths) PerformerJSONPath(checksum string) string {
|
func (jp *jsonPaths) PerformerJSONPath(checksum string) string {
|
||||||
@@ -59,6 +63,10 @@ func (jp *jsonPaths) StudioJSONPath(checksum string) string {
|
|||||||
return filepath.Join(jp.Studios, checksum+".json")
|
return filepath.Join(jp.Studios, checksum+".json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (jp *jsonPaths) TagJSONPath(checksum string) string {
|
||||||
|
return filepath.Join(jp.Tags, checksum+".json")
|
||||||
|
}
|
||||||
|
|
||||||
func (jp *jsonPaths) MovieJSONPath(checksum string) string {
|
func (jp *jsonPaths) MovieJSONPath(checksum string) string {
|
||||||
return filepath.Join(jp.Movies, checksum+".json")
|
return filepath.Join(jp.Movies, checksum+".json")
|
||||||
}
|
}
|
||||||
|
|||||||
25
pkg/manager/tag.go
Normal file
25
pkg/manager/tag.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EnsureTagNameUnique(name string, tx *sqlx.Tx) error {
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
|
||||||
|
// ensure name is unique
|
||||||
|
sameNameTag, err := qb.FindByName(name, tx, true)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if sameNameTag != nil {
|
||||||
|
return fmt.Errorf("Tag with name '%s' already exists", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ func (t *ExportTask) Start(wg *sync.WaitGroup) {
|
|||||||
t.ExportPerformers(ctx, workerCount)
|
t.ExportPerformers(ctx, workerCount)
|
||||||
t.ExportStudios(ctx, workerCount)
|
t.ExportStudios(ctx, workerCount)
|
||||||
t.ExportMovies(ctx, workerCount)
|
t.ExportMovies(ctx, workerCount)
|
||||||
|
t.ExportTags(ctx, workerCount)
|
||||||
|
|
||||||
if err := instance.JSON.saveMappings(t.Mappings); err != nil {
|
if err := instance.JSON.saveMappings(t.Mappings); err != nil {
|
||||||
logger.Errorf("[mappings] failed to save json: %s", err.Error())
|
logger.Errorf("[mappings] failed to save json: %s", err.Error())
|
||||||
@@ -160,7 +161,7 @@ func exportScene(wg *sync.WaitGroup, jobChan <-chan *models.Scene, t *ExportTask
|
|||||||
logger.Errorf("[scenes] <%s> invalid tags for scene marker: %s", scene.Checksum, err.Error())
|
logger.Errorf("[scenes] <%s> invalid tags for scene marker: %s", scene.Checksum, err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if sceneMarker.Title == "" || sceneMarker.Seconds == 0 || primaryTag.Name == "" {
|
if sceneMarker.Seconds == 0 || primaryTag.Name == "" {
|
||||||
logger.Errorf("[scenes] invalid scene marker: %v", sceneMarker)
|
logger.Errorf("[scenes] invalid scene marker: %v", sceneMarker)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +459,81 @@ func exportStudio(wg *sync.WaitGroup, jobChan <-chan *models.Studio) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ExportTask) ExportTags(ctx context.Context, workers int) {
|
||||||
|
var tagsWg sync.WaitGroup
|
||||||
|
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
tags, err := qb.All()
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[tags] failed to fetch all tags: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("[tags] exporting")
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
jobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers
|
||||||
|
|
||||||
|
for w := 0; w < workers; w++ { // create export Tag workers
|
||||||
|
tagsWg.Add(1)
|
||||||
|
go exportTag(&tagsWg, jobCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tag := range tags {
|
||||||
|
index := i + 1
|
||||||
|
logger.Progressf("[tags] %d of %d", index, len(tags))
|
||||||
|
|
||||||
|
// generate checksum on the fly by name, since we don't store it
|
||||||
|
checksum := utils.MD5FromString(tag.Name)
|
||||||
|
|
||||||
|
t.Mappings.Tags = append(t.Mappings.Tags, jsonschema.NameMapping{Name: tag.Name, Checksum: checksum})
|
||||||
|
jobCh <- tag // feed workers
|
||||||
|
}
|
||||||
|
|
||||||
|
close(jobCh)
|
||||||
|
tagsWg.Wait()
|
||||||
|
|
||||||
|
logger.Infof("[tags] export complete in %s. %d workers used.", time.Since(startTime), workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportTag(wg *sync.WaitGroup, jobChan <-chan *models.Tag) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
tagQB := models.NewTagQueryBuilder()
|
||||||
|
|
||||||
|
for tag := range jobChan {
|
||||||
|
|
||||||
|
newTagJSON := jsonschema.Tag{
|
||||||
|
Name: tag.Name,
|
||||||
|
CreatedAt: models.JSONTime{Time: tag.CreatedAt.Timestamp},
|
||||||
|
UpdatedAt: models.JSONTime{Time: tag.UpdatedAt.Timestamp},
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := tagQB.GetTagImage(tag.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[tags] <%s> error getting tag image: %s", tag.Name, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(image) > 0 {
|
||||||
|
newTagJSON.Image = utils.GetBase64StringFromData(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate checksum on the fly by name, since we don't store it
|
||||||
|
checksum := utils.MD5FromString(tag.Name)
|
||||||
|
|
||||||
|
tagJSON, err := instance.JSON.getTag(checksum)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("[tags] error reading tag json: %s", err.Error())
|
||||||
|
} else if jsonschema.CompareJSON(*tagJSON, newTagJSON) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := instance.JSON.saveTag(checksum, &newTagJSON); err != nil {
|
||||||
|
logger.Errorf("[tags] <%s> failed to save json: %s", checksum, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ExportTask) ExportMovies(ctx context.Context, workers int) {
|
func (t *ExportTask) ExportMovies(ctx context.Context, workers int) {
|
||||||
var moviesWg sync.WaitGroup
|
var moviesWg sync.WaitGroup
|
||||||
|
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ func (t *ImportTask) Start(wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
ctx := context.TODO()
|
ctx := context.TODO()
|
||||||
|
|
||||||
|
t.ImportTags(ctx)
|
||||||
t.ImportPerformers(ctx)
|
t.ImportPerformers(ctx)
|
||||||
t.ImportStudios(ctx)
|
t.ImportStudios(ctx)
|
||||||
t.ImportMovies(ctx)
|
t.ImportMovies(ctx)
|
||||||
t.ImportGalleries(ctx)
|
t.ImportGalleries(ctx)
|
||||||
t.ImportTags(ctx)
|
|
||||||
|
|
||||||
t.ImportScrapedItems(ctx)
|
t.ImportScrapedItems(ctx)
|
||||||
t.ImportScenes(ctx)
|
t.ImportScenes(ctx)
|
||||||
@@ -415,61 +415,52 @@ func (t *ImportTask) ImportTags(ctx context.Context) {
|
|||||||
tx := database.DB.MustBeginTx(ctx, nil)
|
tx := database.DB.MustBeginTx(ctx, nil)
|
||||||
qb := models.NewTagQueryBuilder()
|
qb := models.NewTagQueryBuilder()
|
||||||
|
|
||||||
var tagNames []string
|
for i, mappingJSON := range t.Mappings.Tags {
|
||||||
|
|
||||||
for i, mappingJSON := range t.Mappings.Scenes {
|
|
||||||
index := i + 1
|
index := i + 1
|
||||||
if mappingJSON.Checksum == "" || mappingJSON.Path == "" {
|
tagJSON, err := instance.JSON.getTag(mappingJSON.Checksum)
|
||||||
_ = tx.Rollback()
|
if err != nil {
|
||||||
logger.Warn("[tags] scene mapping without checksum or path: ", mappingJSON)
|
logger.Errorf("[tags] failed to read json: %s", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mappingJSON.Checksum == "" || mappingJSON.Name == "" || tagJSON == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Progressf("[tags] %d of %d scenes", index, len(t.Mappings.Scenes))
|
logger.Progressf("[tags] %d of %d", index, len(t.Mappings.Tags))
|
||||||
|
|
||||||
sceneJSON, err := instance.JSON.getScene(mappingJSON.Checksum)
|
// Process the base 64 encoded image string
|
||||||
if err != nil {
|
var imageData []byte
|
||||||
logger.Infof("[tags] <%s> json parse failure: %s", mappingJSON.Checksum, err.Error())
|
if len(tagJSON.Image) > 0 {
|
||||||
}
|
_, imageData, err = utils.ProcessBase64Image(tagJSON.Image)
|
||||||
// Return early if we are missing a json file.
|
if err != nil {
|
||||||
if sceneJSON == nil {
|
_ = tx.Rollback()
|
||||||
continue
|
logger.Errorf("[tags] <%s> invalid image: %s", mappingJSON.Checksum, err.Error())
|
||||||
}
|
return
|
||||||
|
|
||||||
// Get the tags from the tags json if we have it
|
|
||||||
if len(sceneJSON.Tags) > 0 {
|
|
||||||
tagNames = append(tagNames, sceneJSON.Tags...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the tags from the markers if we have marker json
|
|
||||||
if len(sceneJSON.Markers) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, markerJSON := range sceneJSON.Markers {
|
|
||||||
if markerJSON.PrimaryTag != "" {
|
|
||||||
tagNames = append(tagNames, markerJSON.PrimaryTag)
|
|
||||||
}
|
|
||||||
if len(markerJSON.Tags) > 0 {
|
|
||||||
tagNames = append(tagNames, markerJSON.Tags...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
uniqueTagNames := t.getUnique(tagNames)
|
// Populate a new tag from the input
|
||||||
for _, tagName := range uniqueTagNames {
|
|
||||||
currentTime := time.Now()
|
|
||||||
newTag := models.Tag{
|
newTag := models.Tag{
|
||||||
Name: tagName,
|
Name: tagJSON.Name,
|
||||||
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
CreatedAt: models.SQLiteTimestamp{Timestamp: t.getTimeFromJSONTime(tagJSON.CreatedAt)},
|
||||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
UpdatedAt: models.SQLiteTimestamp{Timestamp: t.getTimeFromJSONTime(tagJSON.UpdatedAt)},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := qb.Create(newTag, tx)
|
createdTag, err := qb.Create(newTag, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
logger.Errorf("[tags] <%s> failed to create: %s", tagName, err.Error())
|
logger.Errorf("[tags] <%s> failed to create: %s", mappingJSON.Checksum, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the tag image if set
|
||||||
|
if len(imageData) > 0 {
|
||||||
|
if err := qb.UpdateTagImage(createdTag.ID, imageData, tx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
logger.Errorf("[tags] <%s> error setting tag image: %s", mappingJSON.Checksum, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("[tags] importing")
|
logger.Info("[tags] importing")
|
||||||
|
|||||||
@@ -6,3 +6,132 @@ type Tag struct {
|
|||||||
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
|
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Original Tag image from: https://fontawesome.com/icons/tag?style=solid
|
||||||
|
// Modified to change color and rotate
|
||||||
|
// Licensed under CC Attribution 4.0: https://fontawesome.com/license
|
||||||
|
var DefaultTagImage = []byte(`<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.4 r9939"
|
||||||
|
sodipodi:docname="tag.svg">
|
||||||
|
<defs
|
||||||
|
id="defs4" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#000000"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="1"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="1"
|
||||||
|
inkscape:cx="181.77771"
|
||||||
|
inkscape:cy="279.72376"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false"
|
||||||
|
fit-margin-top="0"
|
||||||
|
fit-margin-left="0"
|
||||||
|
fit-margin-right="0"
|
||||||
|
fit-margin-bottom="0"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1017"
|
||||||
|
inkscape:window-x="-8"
|
||||||
|
inkscape:window-y="-8"
|
||||||
|
inkscape:window-maximized="1" />
|
||||||
|
<metadata
|
||||||
|
id="metadata7">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-157.84358,-524.69522)">
|
||||||
|
<path
|
||||||
|
id="path2987"
|
||||||
|
d="m 229.94314,669.26549 -36.08466,-36.08466 c -4.68653,-4.68653 -4.68653,-12.28468 0,-16.97121 l 36.08466,-36.08467 a 12.000453,12.000453 0 0 1 8.4856,-3.5148 l 74.91443,0 c 6.62761,0 12.00041,5.3728 12.00041,12.00041 l 0,72.16933 c 0,6.62761 -5.3728,12.00041 -12.00041,12.00041 l -74.91443,0 a 12.000453,12.000453 0 0 1 -8.4856,-3.51481 z m -13.45639,-53.05587 c -4.68653,4.68653 -4.68653,12.28468 0,16.97121 4.68652,4.68652 12.28467,4.68652 16.9712,0 4.68653,-4.68653 4.68653,-12.28468 0,-16.97121 -4.68653,-4.68652 -12.28468,-4.68652 -16.9712,0 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#ffffff;fill-opacity:1" />
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
// var DefaultTagImage = []byte(`<svg
|
||||||
|
// xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
// xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
// xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
// xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
// xmlns="http://www.w3.org/2000/svg"
|
||||||
|
// xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
// xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
// width="600"
|
||||||
|
// height="600"
|
||||||
|
// id="svg2"
|
||||||
|
// version="1.1"
|
||||||
|
// inkscape:version="0.48.4 r9939"
|
||||||
|
// sodipodi:docname="New document 1">
|
||||||
|
// <defs
|
||||||
|
// id="defs4" />
|
||||||
|
// <sodipodi:namedview
|
||||||
|
// id="base"
|
||||||
|
// pagecolor="#000000"
|
||||||
|
// bordercolor="#666666"
|
||||||
|
// borderopacity="1.0"
|
||||||
|
// inkscape:pageopacity="1"
|
||||||
|
// inkscape:pageshadow="2"
|
||||||
|
// inkscape:zoom="0.82173542"
|
||||||
|
// inkscape:cx="181.77771"
|
||||||
|
// inkscape:cy="159.72376"
|
||||||
|
// inkscape:document-units="px"
|
||||||
|
// inkscape:current-layer="layer1"
|
||||||
|
// showgrid="false"
|
||||||
|
// fit-margin-top="0"
|
||||||
|
// fit-margin-left="0"
|
||||||
|
// fit-margin-right="0"
|
||||||
|
// fit-margin-bottom="0"
|
||||||
|
// inkscape:window-width="1920"
|
||||||
|
// inkscape:window-height="1017"
|
||||||
|
// inkscape:window-x="-8"
|
||||||
|
// inkscape:window-y="-8"
|
||||||
|
// inkscape:window-maximized="1" />
|
||||||
|
// <metadata
|
||||||
|
// id="metadata7">
|
||||||
|
// <rdf:RDF>
|
||||||
|
// <cc:Work
|
||||||
|
// rdf:about="">
|
||||||
|
// <dc:format>image/svg+xml</dc:format>
|
||||||
|
// <dc:type
|
||||||
|
// rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
// <dc:title></dc:title>
|
||||||
|
// </cc:Work>
|
||||||
|
// </rdf:RDF>
|
||||||
|
// </metadata>
|
||||||
|
// <g
|
||||||
|
// inkscape:label="Layer 1"
|
||||||
|
// inkscape:groupmode="layer"
|
||||||
|
// id="layer1"
|
||||||
|
// transform="translate(-157.84358,-124.69522)">
|
||||||
|
// <path
|
||||||
|
// id="path2987"
|
||||||
|
// d="M 346.24605,602.96957 201.91282,458.63635 c -18.7454,-18.7454 -18.7454,-49.13685 0,-67.88225 L 346.24605,246.42087 a 48,48 0 0 1 33.94111,-14.05869 l 299.64641,0 c 26.50943,0 47.99982,21.49039 47.99982,47.99982 l 0,288.66645 c 0,26.50943 -21.49039,47.99982 -47.99983,47.99982 l -299.64639,0 a 48,48 0 0 1 -33.94112,-14.0587 z M 292.42249,390.7541 c -18.7454,18.7454 -18.7454,49.13685 0,67.88225 18.7454,18.7454 49.13685,18.7454 67.88225,0 18.7454,-18.7454 18.7454,-49.13685 0,-67.88225 -18.7454,-18.7454 -49.13685,-18.7454 -67.88225,0 z"
|
||||||
|
// inkscape:connector-curvature="0"
|
||||||
|
// style="fill:#ffffff;fill-opacity:1" />
|
||||||
|
// </g>
|
||||||
|
// </svg>`)
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ func verifyScenesRating(t *testing.T, ratingCriterion models.IntCriterionInput)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func verifyInt64(t *testing.T, value sql.NullInt64, criterion models.IntCriterionInput) {
|
func verifyInt64(t *testing.T, value sql.NullInt64, criterion models.IntCriterionInput) {
|
||||||
|
t.Helper()
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
if criterion.Modifier == models.CriterionModifierIsNull {
|
if criterion.Modifier == models.CriterionModifierIsNull {
|
||||||
assert.False(value.Valid, "expect is null values to be null")
|
assert.False(value.Valid, "expect is null values to be null")
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func getSort(sort string, direction string, tableName string) string {
|
|||||||
const randomSeedPrefix = "random_"
|
const randomSeedPrefix = "random_"
|
||||||
|
|
||||||
if strings.HasSuffix(sort, "_count") {
|
if strings.HasSuffix(sort, "_count") {
|
||||||
var relationTableName = strings.Split(sort, "_")[0] // TODO: pluralize?
|
var relationTableName = strings.TrimSuffix(sort, "_count") // TODO: pluralize?
|
||||||
colName := getColumn(relationTableName, "id")
|
colName := getColumn(relationTableName, "id")
|
||||||
return " ORDER BY COUNT(distinct " + colName + ") " + direction
|
return " ORDER BY COUNT(distinct " + colName + ") " + direction
|
||||||
} else if strings.Compare(sort, "filesize") == 0 {
|
} else if strings.Compare(sort, "filesize") == 0 {
|
||||||
@@ -325,6 +325,9 @@ func executeFindQuery(tableName string, body string, args []interface{}, sortAnd
|
|||||||
panic(idsErr)
|
panic(idsErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform query and fetch result
|
||||||
|
logger.Tracef("SQL: %s, args: %v", idsQuery, args)
|
||||||
|
|
||||||
return idsResult, countResult
|
return idsResult, countResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"github.com/stashapp/stash/pkg/database"
|
"github.com/stashapp/stash/pkg/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tagTable = "tags"
|
||||||
|
|
||||||
type TagQueryBuilder struct{}
|
type TagQueryBuilder struct{}
|
||||||
|
|
||||||
func NewTagQueryBuilder() TagQueryBuilder {
|
func NewTagQueryBuilder() TagQueryBuilder {
|
||||||
@@ -146,25 +148,60 @@ func (qb *TagQueryBuilder) AllSlim() ([]*Tag, error) {
|
|||||||
return qb.queryTags("SELECT tags.id, tags.name FROM tags "+qb.getTagSort(nil), nil, nil)
|
return qb.queryTags("SELECT tags.id, tags.name FROM tags "+qb.getTagSort(nil), nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (qb *TagQueryBuilder) Query(findFilter *FindFilterType) ([]*Tag, int) {
|
func (qb *TagQueryBuilder) Query(tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int) {
|
||||||
|
if tagFilter == nil {
|
||||||
|
tagFilter = &TagFilterType{}
|
||||||
|
}
|
||||||
if findFilter == nil {
|
if findFilter == nil {
|
||||||
findFilter = &FindFilterType{}
|
findFilter = &FindFilterType{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var whereClauses []string
|
query := queryBuilder{
|
||||||
var havingClauses []string
|
tableName: tagTable,
|
||||||
var args []interface{}
|
}
|
||||||
body := selectDistinctIDs("tags")
|
|
||||||
|
query.body = selectDistinctIDs(tagTable)
|
||||||
|
query.body += `
|
||||||
|
left join tags_image on tags_image.tag_id = tags.id
|
||||||
|
left join scenes_tags on scenes_tags.tag_id = tags.id
|
||||||
|
left join scene_markers_tags on scene_markers_tags.tag_id = tags.id
|
||||||
|
left join scene_markers on scene_markers.primary_tag_id = tags.id OR scene_markers.id = scene_markers_tags.scene_marker_id
|
||||||
|
left join scenes on scenes_tags.scene_id = scenes.id`
|
||||||
|
|
||||||
if q := findFilter.Q; q != nil && *q != "" {
|
if q := findFilter.Q; q != nil && *q != "" {
|
||||||
searchColumns := []string{"tags.name"}
|
searchColumns := []string{"tags.name"}
|
||||||
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
|
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
|
||||||
whereClauses = append(whereClauses, clause)
|
query.addWhere(clause)
|
||||||
args = append(args, thisArgs...)
|
query.addArg(thisArgs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
sortAndPagination := qb.getTagSort(findFilter) + getPagination(findFilter)
|
if isMissingFilter := tagFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||||
idsResult, countResult := executeFindQuery("tags", body, args, sortAndPagination, whereClauses, havingClauses)
|
switch *isMissingFilter {
|
||||||
|
case "image":
|
||||||
|
query.addWhere("tags_image.tag_id IS NULL")
|
||||||
|
default:
|
||||||
|
query.addWhere("tags." + *isMissingFilter + " IS NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sceneCount := tagFilter.SceneCount; sceneCount != nil {
|
||||||
|
clause, count := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount)
|
||||||
|
query.addHaving(clause)
|
||||||
|
if count == 1 {
|
||||||
|
query.addArg(sceneCount.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if markerCount := tagFilter.MarkerCount; markerCount != nil {
|
||||||
|
clause, count := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount)
|
||||||
|
query.addHaving(clause)
|
||||||
|
if count == 1 {
|
||||||
|
query.addArg(markerCount.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query.sortAndPagination = qb.getTagSort(findFilter) + getPagination(findFilter)
|
||||||
|
idsResult, countResult := query.executeFind()
|
||||||
|
|
||||||
var tags []*Tag
|
var tags []*Tag
|
||||||
for _, id := range idsResult {
|
for _, id := range idsResult {
|
||||||
@@ -225,3 +262,36 @@ func (qb *TagQueryBuilder) queryTags(query string, args []interface{}, tx *sqlx.
|
|||||||
|
|
||||||
return tags, nil
|
return tags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (qb *TagQueryBuilder) UpdateTagImage(tagID int, image []byte, tx *sqlx.Tx) error {
|
||||||
|
ensureTx(tx)
|
||||||
|
|
||||||
|
// Delete the existing cover and then create new
|
||||||
|
if err := qb.DestroyTagImage(tagID, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.Exec(
|
||||||
|
`INSERT INTO tags_image (tag_id, image) VALUES (?, ?)`,
|
||||||
|
tagID,
|
||||||
|
image,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qb *TagQueryBuilder) DestroyTagImage(tagID int, tx *sqlx.Tx) error {
|
||||||
|
ensureTx(tx)
|
||||||
|
|
||||||
|
// Delete the existing joins
|
||||||
|
_, err := tx.Exec("DELETE FROM tags_image WHERE tag_id = ?", tagID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qb *TagQueryBuilder) GetTagImage(tagID int, tx *sqlx.Tx) ([]byte, error) {
|
||||||
|
query := `SELECT image from tags_image WHERE tag_id = ?`
|
||||||
|
return getImage(tx, query, tagID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
package models_test
|
package models_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/database"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -106,6 +109,197 @@ func TestTagFindByNames(t *testing.T) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagQueryIsMissingImage(t *testing.T) {
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
isMissing := "image"
|
||||||
|
tagFilter := models.TagFilterType{
|
||||||
|
IsMissing: &isMissing,
|
||||||
|
}
|
||||||
|
|
||||||
|
q := getTagStringValue(tagIdxWithImage, "name")
|
||||||
|
findFilter := models.FindFilterType{
|
||||||
|
Q: &q,
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, _ := qb.Query(&tagFilter, &findFilter)
|
||||||
|
|
||||||
|
assert.Len(t, tags, 0)
|
||||||
|
|
||||||
|
findFilter.Q = nil
|
||||||
|
tags, _ = qb.Query(&tagFilter, &findFilter)
|
||||||
|
|
||||||
|
// ensure non of the ids equal the one with image
|
||||||
|
for _, tag := range tags {
|
||||||
|
assert.NotEqual(t, tagIDs[tagIdxWithImage], tag.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagQuerySceneCount(t *testing.T) {
|
||||||
|
countCriterion := models.IntCriterionInput{
|
||||||
|
Value: 1,
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyTagSceneCount(t, countCriterion)
|
||||||
|
|
||||||
|
countCriterion.Modifier = models.CriterionModifierNotEquals
|
||||||
|
verifyTagSceneCount(t, countCriterion)
|
||||||
|
|
||||||
|
countCriterion.Modifier = models.CriterionModifierLessThan
|
||||||
|
verifyTagSceneCount(t, countCriterion)
|
||||||
|
|
||||||
|
countCriterion.Value = 0
|
||||||
|
countCriterion.Modifier = models.CriterionModifierGreaterThan
|
||||||
|
verifyTagSceneCount(t, countCriterion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTagSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) {
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
tagFilter := models.TagFilterType{
|
||||||
|
SceneCount: &sceneCountCriterion,
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, _ := qb.Query(&tagFilter, nil)
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
verifyInt64(t, sql.NullInt64{
|
||||||
|
Int64: int64(getTagSceneCount(tag.ID)),
|
||||||
|
Valid: true,
|
||||||
|
}, sceneCountCriterion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagQueryMarkerCount(t *testing.T) {
|
||||||
|
countCriterion := models.IntCriterionInput{
|
||||||
|
Value: 1,
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyTagMarkerCount(t, countCriterion)
|
||||||
|
|
||||||
|
countCriterion.Modifier = models.CriterionModifierNotEquals
|
||||||
|
verifyTagMarkerCount(t, countCriterion)
|
||||||
|
|
||||||
|
countCriterion.Modifier = models.CriterionModifierLessThan
|
||||||
|
verifyTagMarkerCount(t, countCriterion)
|
||||||
|
|
||||||
|
countCriterion.Value = 0
|
||||||
|
countCriterion.Modifier = models.CriterionModifierGreaterThan
|
||||||
|
verifyTagMarkerCount(t, countCriterion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTagMarkerCount(t *testing.T, markerCountCriterion models.IntCriterionInput) {
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
tagFilter := models.TagFilterType{
|
||||||
|
MarkerCount: &markerCountCriterion,
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, _ := qb.Query(&tagFilter, nil)
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
verifyInt64(t, sql.NullInt64{
|
||||||
|
Int64: int64(getTagMarkerCount(tag.ID)),
|
||||||
|
Valid: true,
|
||||||
|
}, markerCountCriterion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagUpdateTagImage(t *testing.T) {
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
|
||||||
|
// create tag to test against
|
||||||
|
ctx := context.TODO()
|
||||||
|
tx := database.DB.MustBeginTx(ctx, nil)
|
||||||
|
|
||||||
|
const name = "TestTagUpdateTagImage"
|
||||||
|
tag := models.Tag{
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
created, err := qb.Create(tag, tx)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("Error creating tag: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
image := []byte("image")
|
||||||
|
err = qb.UpdateTagImage(created.ID, image, tx)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("Error updating studio image: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("Error committing: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure image set
|
||||||
|
storedImage, err := qb.GetTagImage(created.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error getting image: %s", err.Error())
|
||||||
|
}
|
||||||
|
assert.Equal(t, storedImage, image)
|
||||||
|
|
||||||
|
// set nil image
|
||||||
|
tx = database.DB.MustBeginTx(ctx, nil)
|
||||||
|
err = qb.UpdateTagImage(created.ID, nil, tx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Expected error setting nil image")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDestroyTagImage(t *testing.T) {
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
|
||||||
|
// create performer to test against
|
||||||
|
ctx := context.TODO()
|
||||||
|
tx := database.DB.MustBeginTx(ctx, nil)
|
||||||
|
|
||||||
|
const name = "TestTagDestroyTagImage"
|
||||||
|
tag := models.Tag{
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
created, err := qb.Create(tag, tx)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("Error creating tag: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
image := []byte("image")
|
||||||
|
err = qb.UpdateTagImage(created.ID, image, tx)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("Error updating studio image: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("Error committing: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
tx = database.DB.MustBeginTx(ctx, nil)
|
||||||
|
|
||||||
|
err = qb.DestroyTagImage(created.ID, tx)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("Error destroying studio image: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("Error committing: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// image should be nil
|
||||||
|
storedImage, err := qb.GetTagImage(created.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error getting image: %s", err.Error())
|
||||||
|
}
|
||||||
|
assert.Nil(t, storedImage)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO Create
|
// TODO Create
|
||||||
// TODO Update
|
// TODO Update
|
||||||
// TODO Destroy
|
// TODO Destroy
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const moviesNameCase = 2
|
|||||||
const moviesNameNoCase = 1
|
const moviesNameNoCase = 1
|
||||||
const totalGalleries = 2
|
const totalGalleries = 2
|
||||||
const tagsNameNoCase = 2
|
const tagsNameNoCase = 2
|
||||||
const tagsNameCase = 5
|
const tagsNameCase = 6
|
||||||
const studiosNameCase = 4
|
const studiosNameCase = 4
|
||||||
const studiosNameNoCase = 1
|
const studiosNameNoCase = 1
|
||||||
|
|
||||||
@@ -73,10 +73,11 @@ const tagIdx1WithScene = 1
|
|||||||
const tagIdx2WithScene = 2
|
const tagIdx2WithScene = 2
|
||||||
const tagIdxWithPrimaryMarker = 3
|
const tagIdxWithPrimaryMarker = 3
|
||||||
const tagIdxWithMarker = 4
|
const tagIdxWithMarker = 4
|
||||||
|
const tagIdxWithImage = 5
|
||||||
|
|
||||||
// tags with dup names start from the end
|
// tags with dup names start from the end
|
||||||
const tagIdx1WithDupName = 5
|
const tagIdx1WithDupName = 6
|
||||||
const tagIdxWithDupName = 6
|
const tagIdxWithDupName = 7
|
||||||
|
|
||||||
const studioIdxWithScene = 0
|
const studioIdxWithScene = 0
|
||||||
const studioIdxWithMovie = 1
|
const studioIdxWithMovie = 1
|
||||||
@@ -162,6 +163,11 @@ func populateDB() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := addTagImage(tx, tagIdxWithImage); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := createStudios(tx, studiosNameCase, studiosNameNoCase); err != nil {
|
if err := createStudios(tx, studiosNameCase, studiosNameNoCase); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
@@ -404,6 +410,22 @@ func getTagStringValue(index int, field string) string {
|
|||||||
return "tag_" + strconv.FormatInt(int64(index), 10) + "_" + field
|
return "tag_" + strconv.FormatInt(int64(index), 10) + "_" + field
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTagSceneCount(id int) int {
|
||||||
|
if id == tagIDs[tagIdx1WithScene] || id == tagIDs[tagIdx2WithScene] || id == tagIDs[tagIdxWithScene] {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTagMarkerCount(id int) int {
|
||||||
|
if id == tagIDs[tagIdxWithMarker] || id == tagIDs[tagIdxWithPrimaryMarker] {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
//createTags creates n tags with plain Name and o tags with camel cased NaMe included
|
//createTags creates n tags with plain Name and o tags with camel cased NaMe included
|
||||||
func createTags(tx *sqlx.Tx, n int, o int) error {
|
func createTags(tx *sqlx.Tx, n int, o int) error {
|
||||||
tqb := models.NewTagQueryBuilder()
|
tqb := models.NewTagQueryBuilder()
|
||||||
@@ -433,7 +455,6 @@ func createTags(tx *sqlx.Tx, n int, o int) error {
|
|||||||
|
|
||||||
tagIDs = append(tagIDs, created.ID)
|
tagIDs = append(tagIDs, created.ID)
|
||||||
tagNames = append(tagNames, created.Name)
|
tagNames = append(tagNames, created.Name)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -630,3 +651,9 @@ func linkStudioParent(tx *sqlx.Tx, parentIndex, childIndex int) error {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addTagImage(tx *sqlx.Tx, tagIndex int) error {
|
||||||
|
qb := models.NewTagQueryBuilder()
|
||||||
|
|
||||||
|
return qb.UpdateTagImage(tagIDs[tagIndex], models.DefaultTagImage, tx)
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ func ServeImage(image []byte, w http.ResponseWriter, r *http.Request) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contentType := http.DetectContentType(image)
|
||||||
|
if contentType == "text/xml; charset=utf-8" || contentType == "text/plain; charset=utf-8" {
|
||||||
|
contentType = "image/svg+xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
w.Header().Add("Etag", etag)
|
w.Header().Add("Etag", etag)
|
||||||
_, err := w.Write(image)
|
_, err := w.Write(image)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import Scenes from "./components/Scenes/Scenes";
|
|||||||
import { Settings } from "./components/Settings/Settings";
|
import { Settings } from "./components/Settings/Settings";
|
||||||
import { Stats } from "./components/Stats";
|
import { Stats } from "./components/Stats";
|
||||||
import Studios from "./components/Studios/Studios";
|
import Studios from "./components/Studios/Studios";
|
||||||
import { TagList } from "./components/Tags/TagList";
|
|
||||||
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
||||||
import Movies from "./components/Movies/Movies";
|
import Movies from "./components/Movies/Movies";
|
||||||
|
import Tags from "./components/Tags/Tags";
|
||||||
|
|
||||||
// Set fontawesome/free-solid-svg as default fontawesome icons
|
// Set fontawesome/free-solid-svg as default fontawesome icons
|
||||||
library.add(fas);
|
library.add(fas);
|
||||||
@@ -51,7 +51,7 @@ export const App: React.FC = () => {
|
|||||||
<Route path="/scenes" component={Scenes} />
|
<Route path="/scenes" component={Scenes} />
|
||||||
<Route path="/galleries" component={Galleries} />
|
<Route path="/galleries" component={Galleries} />
|
||||||
<Route path="/performers" component={Performers} />
|
<Route path="/performers" component={Performers} />
|
||||||
<Route path="/tags" component={TagList} />
|
<Route path="/tags" component={Tags} />
|
||||||
<Route path="/studios" component={Studios} />
|
<Route path="/studios" component={Studios} />
|
||||||
<Route path="/movies" component={Movies} />
|
<Route path="/movies" component={Movies} />
|
||||||
<Route path="/settings" component={Settings} />
|
<Route path="/settings" component={Settings} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
|
|
||||||
const markup = `
|
const markup = `
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
* Add tag thumbnails, tags grid view and tag page.
|
||||||
* Add post-scrape dialog.
|
* Add post-scrape dialog.
|
||||||
* Add various keyboard shortcuts (see manual).
|
* Add various keyboard shortcuts (see manual).
|
||||||
* Support deleting multiple scenes.
|
* Support deleting multiple scenes.
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ export const MainNavbar: React.FC = () => {
|
|||||||
? "/studios/new"
|
? "/studios/new"
|
||||||
: location.pathname === "/movies"
|
: location.pathname === "/movies"
|
||||||
? "/movies/new"
|
? "/movies/new"
|
||||||
|
: location.pathname === "/tags"
|
||||||
|
? "/tags/new"
|
||||||
: null;
|
: null;
|
||||||
const newButton =
|
const newButton =
|
||||||
newPath === null ? (
|
newPath === null ? (
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import { ListFilterModel } from "src/models/list-filter/filter";
|
|||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
import { WallPanel } from "../Wall/WallPanel";
|
import { WallPanel } from "../Wall/WallPanel";
|
||||||
|
|
||||||
export const SceneMarkerList: React.FC = () => {
|
interface ISceneMarkerList {
|
||||||
|
subComponent?: boolean;
|
||||||
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneMarkerList: React.FC<ISceneMarkerList> = ({
|
||||||
|
subComponent,
|
||||||
|
filterHook,
|
||||||
|
}) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
{
|
{
|
||||||
@@ -34,6 +42,8 @@ export const SceneMarkerList: React.FC = () => {
|
|||||||
const listData = useSceneMarkersList({
|
const listData = useSceneMarkersList({
|
||||||
otherOperations,
|
otherOperations,
|
||||||
renderContent,
|
renderContent,
|
||||||
|
subComponent,
|
||||||
|
filterHook,
|
||||||
addKeybinds,
|
addKeybinds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
value={logLevel}
|
value={logLevel}
|
||||||
>
|
>
|
||||||
{["Debug", "Info", "Warning", "Error"].map((o) => (
|
{["Trace", "Debug", "Info", "Warning", "Error"].map((o) => (
|
||||||
<option key={o} value={o}>
|
<option key={o} value={o}>
|
||||||
{o}
|
{o}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class LogEntry {
|
|||||||
// maximum number of log entries to display. Subsequent entries will truncate
|
// maximum number of log entries to display. Subsequent entries will truncate
|
||||||
// the list, dropping off the oldest entries first.
|
// the list, dropping off the oldest entries first.
|
||||||
const MAX_LOG_ENTRIES = 200;
|
const MAX_LOG_ENTRIES = 200;
|
||||||
const logLevels = ["Debug", "Info", "Warning", "Error"];
|
const logLevels = ["Trace", "Debug", "Info", "Warning", "Error"];
|
||||||
|
|
||||||
const logReducer = (existingEntries: LogEntry[], newEntries: LogEntry[]) => [
|
const logReducer = (existingEntries: LogEntry[], newEntries: LogEntry[]) => [
|
||||||
...newEntries.reverse(),
|
...newEntries.reverse(),
|
||||||
@@ -96,7 +96,7 @@ export const SettingsLogsPanel: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
function filterByLogLevel(logEntry: LogEntry) {
|
function filterByLogLevel(logEntry: LogEntry) {
|
||||||
if (logLevel === "Debug") return true;
|
if (logLevel === "Trace") return true;
|
||||||
|
|
||||||
const logLevelIndex = logLevels.indexOf(logLevel);
|
const logLevelIndex = logLevels.indexOf(logLevel);
|
||||||
const levelIndex = logLevels.indexOf(logEntry.level);
|
const levelIndex = logLevels.indexOf(logEntry.level);
|
||||||
|
|||||||
69
ui/v2.5/src/components/Tags/TagCard.tsx
Normal file
69
ui/v2.5/src/components/Tags/TagCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Card, Button, ButtonGroup } from "react-bootstrap";
|
||||||
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { NavUtils } from "src/utils";
|
||||||
|
import { Icon } from "../Shared";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
tag: GQL.TagDataFragment;
|
||||||
|
zoomIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagCard: React.FC<IProps> = ({ tag, zoomIndex }) => {
|
||||||
|
function maybeRenderScenesPopoverButton() {
|
||||||
|
if (!tag.scene_count) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={NavUtils.makeTagScenesUrl(tag)}>
|
||||||
|
<Button className="minimal">
|
||||||
|
<Icon icon="play-circle" />
|
||||||
|
<span>{tag.scene_count}</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderSceneMarkersPopoverButton() {
|
||||||
|
if (!tag.scene_marker_count) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={NavUtils.makeTagSceneMarkersUrl(tag)}>
|
||||||
|
<Button className="minimal">
|
||||||
|
<Icon icon="map-marker-alt" />
|
||||||
|
<span>{tag.scene_marker_count}</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderPopoverButtonGroup() {
|
||||||
|
if (tag) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<hr />
|
||||||
|
<ButtonGroup className="card-popovers">
|
||||||
|
{maybeRenderScenesPopoverButton()}
|
||||||
|
{maybeRenderSceneMarkersPopoverButton()}
|
||||||
|
</ButtonGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`tag-card zoom-${zoomIndex}`}>
|
||||||
|
<Link to={`/tags/${tag.id}`} className="tag-card-header">
|
||||||
|
<img
|
||||||
|
className="tag-card-image"
|
||||||
|
alt={tag.name}
|
||||||
|
src={tag.image_path ?? ""}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="card-section">
|
||||||
|
<h5 className="text-truncate">{tag.name}</h5>
|
||||||
|
</div>
|
||||||
|
{maybeRenderPopoverButtonGroup()}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
228
ui/v2.5/src/components/Tags/TagDetails/Tag.tsx
Normal file
228
ui/v2.5/src/components/Tags/TagDetails/Tag.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
/* eslint-disable react/no-this-in-sfc */
|
||||||
|
|
||||||
|
import { Table, Tabs, Tab } from "react-bootstrap";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useParams, useHistory } from "react-router-dom";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import {
|
||||||
|
useFindTag,
|
||||||
|
useTagUpdate,
|
||||||
|
useTagCreate,
|
||||||
|
useTagDestroy,
|
||||||
|
mutateMetadataAutoTag,
|
||||||
|
} from "src/core/StashService";
|
||||||
|
import { ImageUtils, TableUtils } from "src/utils";
|
||||||
|
import {
|
||||||
|
DetailsEditNavbar,
|
||||||
|
Modal,
|
||||||
|
LoadingIndicator,
|
||||||
|
} from "src/components/Shared";
|
||||||
|
import { useToast } from "src/hooks";
|
||||||
|
import { TagScenesPanel } from "./TagScenesPanel";
|
||||||
|
import { TagMarkersPanel } from "./TagMarkersPanel";
|
||||||
|
|
||||||
|
export const Tag: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const Toast = useToast();
|
||||||
|
const { id = "new" } = useParams();
|
||||||
|
const isNew = id === "new";
|
||||||
|
|
||||||
|
// Editing state
|
||||||
|
const [isEditing, setIsEditing] = useState<boolean>(isNew);
|
||||||
|
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Editing tag state
|
||||||
|
const [image, setImage] = useState<string>();
|
||||||
|
const [name, setName] = useState<string>();
|
||||||
|
|
||||||
|
// Tag state
|
||||||
|
const [tag, setTag] = useState<Partial<GQL.TagDataFragment>>({});
|
||||||
|
const [imagePreview, setImagePreview] = useState<string>();
|
||||||
|
|
||||||
|
const { data, error, loading } = useFindTag(id);
|
||||||
|
const [updateTag] = useTagUpdate(getTagInput() as GQL.TagUpdateInput);
|
||||||
|
const [createTag] = useTagCreate(getTagInput() as GQL.TagUpdateInput);
|
||||||
|
const [deleteTag] = useTagDestroy(getTagInput() as GQL.TagUpdateInput);
|
||||||
|
|
||||||
|
// set up hotkeys
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
Mousetrap.bind("s s", () => onSave());
|
||||||
|
}
|
||||||
|
|
||||||
|
Mousetrap.bind("e", () => setIsEditing(true));
|
||||||
|
Mousetrap.bind("d d", () => onDelete());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (isEditing) {
|
||||||
|
Mousetrap.unbind("s s");
|
||||||
|
}
|
||||||
|
|
||||||
|
Mousetrap.unbind("e");
|
||||||
|
Mousetrap.unbind("d d");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateTagEditState(state: Partial<GQL.TagDataFragment>) {
|
||||||
|
setName(state.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTagData(tagData: Partial<GQL.TagDataFragment>) {
|
||||||
|
setImage(undefined);
|
||||||
|
updateTagEditState(tagData);
|
||||||
|
setImagePreview(tagData.image_path ?? undefined);
|
||||||
|
setTag(tagData);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && data.findTag) {
|
||||||
|
setImage(undefined);
|
||||||
|
updateTagEditState(data.findTag);
|
||||||
|
setImagePreview(data.findTag.image_path ?? undefined);
|
||||||
|
setTag(data.findTag);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
function onImageLoad(imageData: string) {
|
||||||
|
setImagePreview(imageData);
|
||||||
|
setImage(imageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
|
||||||
|
|
||||||
|
if (!isNew && !isEditing) {
|
||||||
|
if (!data?.findTag || loading) return <LoadingIndicator />;
|
||||||
|
if (error) return <div>{error.message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagInput() {
|
||||||
|
const input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
(input as GQL.TagUpdateInput).id = id;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
try {
|
||||||
|
if (!isNew) {
|
||||||
|
const result = await updateTag();
|
||||||
|
if (result.data?.tagUpdate) {
|
||||||
|
updateTagData(result.data.tagUpdate);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await createTag();
|
||||||
|
if (result.data?.tagCreate?.id) {
|
||||||
|
history.push(`/tags/${result.data.tagCreate.id}`);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAutoTag() {
|
||||||
|
if (!tag.id) return;
|
||||||
|
try {
|
||||||
|
await mutateMetadataAutoTag({ tags: [tag.id] });
|
||||||
|
Toast.success({ content: "Started auto tagging" });
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete() {
|
||||||
|
try {
|
||||||
|
await deleteTag();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirect to tags page
|
||||||
|
history.push(`/tags`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
|
||||||
|
ImageUtils.onImageChange(event, onImageLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeleteAlert() {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
show={isDeleteAlertOpen}
|
||||||
|
icon="trash-alt"
|
||||||
|
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
|
||||||
|
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
|
||||||
|
>
|
||||||
|
<p>Are you sure you want to delete {name ?? "tag"}?</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleEdit() {
|
||||||
|
setIsEditing(!isEditing);
|
||||||
|
updateTagData(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div
|
||||||
|
className={cx("tag-details", {
|
||||||
|
"col-md-4": !isNew,
|
||||||
|
"col-8": isNew,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isNew && <h2>Add Tag</h2>}
|
||||||
|
<div className="text-center">
|
||||||
|
{imageEncoding ? (
|
||||||
|
<LoadingIndicator message="Encoding image..." />
|
||||||
|
) : (
|
||||||
|
<img className="logo" alt={name} src={imagePreview} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<tbody>
|
||||||
|
{TableUtils.renderInputGroup({
|
||||||
|
title: "Name",
|
||||||
|
value: name ?? "",
|
||||||
|
isEditing: !!isEditing,
|
||||||
|
onChange: setName,
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
<DetailsEditNavbar
|
||||||
|
objectName={name ?? "tag"}
|
||||||
|
isNew={isNew}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onToggleEdit={onToggleEdit}
|
||||||
|
onSave={onSave}
|
||||||
|
onImageChange={onImageChangeHandler}
|
||||||
|
onAutoTag={onAutoTag}
|
||||||
|
onDelete={onDelete}
|
||||||
|
acceptSVG
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isNew && (
|
||||||
|
<div className="col col-md-8">
|
||||||
|
<Tabs id="tag-tabs" mountOnEnter>
|
||||||
|
<Tab eventKey="tag-scenes-panel" title="Scenes">
|
||||||
|
<TagScenesPanel tag={tag} />
|
||||||
|
</Tab>
|
||||||
|
<Tab eventKey="tag-markers-panel" title="Markers">
|
||||||
|
<TagMarkersPanel tag={tag} />
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderDeleteAlert()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
45
ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx
Normal file
45
ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
|
||||||
|
import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList";
|
||||||
|
|
||||||
|
interface ITagMarkersPanel {
|
||||||
|
tag: Partial<GQL.TagDataFragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({ tag }) => {
|
||||||
|
function filterHook(filter: ListFilterModel) {
|
||||||
|
const tagValue = { id: tag.id!, label: tag.name! };
|
||||||
|
// if tag is already present, then we modify it, otherwise add
|
||||||
|
let tagCriterion = filter.criteria.find((c) => {
|
||||||
|
return c.type === "tags";
|
||||||
|
}) as TagsCriterion;
|
||||||
|
|
||||||
|
if (
|
||||||
|
tagCriterion &&
|
||||||
|
(tagCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||||
|
tagCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||||
|
) {
|
||||||
|
// add the tag if not present
|
||||||
|
if (
|
||||||
|
!tagCriterion.value.find((p) => {
|
||||||
|
return p.id === tag.id;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
tagCriterion.value.push(tagValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||||
|
} else {
|
||||||
|
// overwrite
|
||||||
|
tagCriterion = new TagsCriterion("tags");
|
||||||
|
tagCriterion.value = [tagValue];
|
||||||
|
filter.criteria.push(tagCriterion);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SceneMarkerList subComponent filterHook={filterHook} />;
|
||||||
|
};
|
||||||
45
ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx
Normal file
45
ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { SceneList } from "src/components/Scenes/SceneList";
|
||||||
|
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
|
||||||
|
|
||||||
|
interface ITagScenesPanel {
|
||||||
|
tag: Partial<GQL.TagDataFragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagScenesPanel: React.FC<ITagScenesPanel> = ({ tag }) => {
|
||||||
|
function filterHook(filter: ListFilterModel) {
|
||||||
|
const tagValue = { id: tag.id!, label: tag.name! };
|
||||||
|
// if tag is already present, then we modify it, otherwise add
|
||||||
|
let tagCriterion = filter.criteria.find((c) => {
|
||||||
|
return c.type === "tags";
|
||||||
|
}) as TagsCriterion;
|
||||||
|
|
||||||
|
if (
|
||||||
|
tagCriterion &&
|
||||||
|
(tagCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||||
|
tagCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||||
|
) {
|
||||||
|
// add the tag if not present
|
||||||
|
if (
|
||||||
|
!tagCriterion.value.find((p) => {
|
||||||
|
return p.id === tag.id;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
tagCriterion.value.push(tagValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||||
|
} else {
|
||||||
|
// overwrite
|
||||||
|
tagCriterion = new TagsCriterion("tags");
|
||||||
|
tagCriterion.value = [tagValue];
|
||||||
|
filter.criteria.push(tagCriterion);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SceneList subComponent filterHook={filterHook} />;
|
||||||
|
};
|
||||||
@@ -1,41 +1,36 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Form } from "react-bootstrap";
|
import { FindTagsQueryResult } from "src/core/generated-graphql";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
|
import { useTagsList } from "src/hooks/ListHook";
|
||||||
|
import { Button } from "react-bootstrap";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { FormattedNumber } from "react-intl";
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import {
|
import { mutateMetadataAutoTag, useTagDestroy } from "src/core/StashService";
|
||||||
mutateMetadataAutoTag,
|
|
||||||
useAllTags,
|
|
||||||
useTagUpdate,
|
|
||||||
useTagCreate,
|
|
||||||
useTagDestroy,
|
|
||||||
} from "src/core/StashService";
|
|
||||||
import { NavUtils } from "src/utils";
|
|
||||||
import { Icon, Modal, LoadingIndicator } from "src/components/Shared";
|
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
|
import { FormattedNumber } from "react-intl";
|
||||||
|
import { NavUtils } from "src/utils";
|
||||||
|
import { TagCard } from "./TagCard";
|
||||||
|
import { Icon, Modal } from "../Shared";
|
||||||
|
|
||||||
export const TagList: React.FC = () => {
|
interface ITagList {
|
||||||
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagList: React.FC<ITagList> = ({ filterHook }) => {
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
// Editing / New state
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [editingTag, setEditingTag] = useState<Partial<
|
|
||||||
GQL.TagDataFragment
|
|
||||||
> | null>(null);
|
|
||||||
const [deletingTag, setDeletingTag] = useState<Partial<
|
const [deletingTag, setDeletingTag] = useState<Partial<
|
||||||
GQL.TagDataFragment
|
GQL.TagDataFragment
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
|
|
||||||
const { data, error } = useAllTags();
|
|
||||||
const [updateTag] = useTagUpdate(getTagInput() as GQL.TagUpdateInput);
|
|
||||||
const [createTag] = useTagCreate(getTagInput() as GQL.TagCreateInput);
|
|
||||||
const [deleteTag] = useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput);
|
const [deleteTag] = useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput);
|
||||||
|
|
||||||
function getTagInput() {
|
const listData = useTagsList({
|
||||||
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name };
|
renderContent,
|
||||||
if (editingTag)
|
filterHook,
|
||||||
(tagInput as Partial<GQL.TagUpdateInput>).id = editingTag.id;
|
zoomable: true,
|
||||||
return tagInput;
|
defaultZoomIndex: 0,
|
||||||
}
|
});
|
||||||
|
|
||||||
function getDeleteTagInput() {
|
function getDeleteTagInput() {
|
||||||
const tagInput: Partial<GQL.TagDestroyInput> = {};
|
const tagInput: Partial<GQL.TagDestroyInput> = {};
|
||||||
@@ -45,21 +40,6 @@ export const TagList: React.FC = () => {
|
|||||||
return tagInput;
|
return tagInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEdit() {
|
|
||||||
try {
|
|
||||||
if (editingTag && editingTag.id) {
|
|
||||||
await updateTag();
|
|
||||||
Toast.success({ content: "Updated tag" });
|
|
||||||
} else {
|
|
||||||
await createTag();
|
|
||||||
Toast.success({ content: "Created tag" });
|
|
||||||
}
|
|
||||||
setEditingTag(null);
|
|
||||||
} catch (e) {
|
|
||||||
Toast.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onAutoTag(tag: GQL.TagDataFragment) {
|
async function onAutoTag(tag: GQL.TagDataFragment) {
|
||||||
if (!tag) return;
|
if (!tag) return;
|
||||||
try {
|
try {
|
||||||
@@ -94,86 +74,79 @@ export const TagList: React.FC = () => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data?.allTags) return <LoadingIndicator />;
|
function renderContent(
|
||||||
if (error) return <div>{error.message}</div>;
|
result: FindTagsQueryResult,
|
||||||
|
filter: ListFilterModel,
|
||||||
|
selectedIds: Set<string>,
|
||||||
|
zoomIndex: number
|
||||||
|
) {
|
||||||
|
if (!result.data?.findTags) return;
|
||||||
|
|
||||||
const tagElements = data.allTags.map((tag) => {
|
if (filter.displayMode === DisplayMode.Grid) {
|
||||||
return (
|
return (
|
||||||
<div key={tag.id} className="tag-list-row row">
|
<div className="row px-xl-5 justify-content-center">
|
||||||
<Button variant="link" onClick={() => setEditingTag(tag)}>
|
{result.data.findTags.tags.map((tag) => (
|
||||||
{tag.name}
|
<TagCard key={tag.id} tag={tag} zoomIndex={zoomIndex} />
|
||||||
</Button>
|
))}
|
||||||
<div className="ml-auto">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="tag-list-button"
|
|
||||||
onClick={() => onAutoTag(tag)}
|
|
||||||
>
|
|
||||||
Auto Tag
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" className="tag-list-button">
|
|
||||||
<Link
|
|
||||||
to={NavUtils.makeTagScenesUrl(tag)}
|
|
||||||
className="tag-list-anchor"
|
|
||||||
>
|
|
||||||
Scenes: <FormattedNumber value={tag.scene_count ?? 0} />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" className="tag-list-button">
|
|
||||||
<Link
|
|
||||||
to={NavUtils.makeTagSceneMarkersUrl(tag)}
|
|
||||||
className="tag-list-anchor"
|
|
||||||
>
|
|
||||||
Markers: <FormattedNumber value={tag.scene_marker_count ?? 0} />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<span className="tag-list-count">
|
|
||||||
Total:{" "}
|
|
||||||
<FormattedNumber
|
|
||||||
value={(tag.scene_count || 0) + (tag.scene_marker_count || 0)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
|
|
||||||
<Icon icon="trash-alt" color="danger" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
});
|
if (filter.displayMode === DisplayMode.List) {
|
||||||
|
const tagElements = result.data.findTags.tags.map((tag) => {
|
||||||
|
return (
|
||||||
|
<div key={tag.id} className="tag-list-row row">
|
||||||
|
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
|
||||||
|
|
||||||
return (
|
<div className="ml-auto">
|
||||||
<div className="col col-sm-8 m-auto">
|
<Button
|
||||||
<Button
|
variant="secondary"
|
||||||
variant="primary"
|
className="tag-list-button"
|
||||||
className="mt-2"
|
onClick={() => onAutoTag(tag)}
|
||||||
onClick={() => setEditingTag({})}
|
>
|
||||||
>
|
Auto Tag
|
||||||
New Tag
|
</Button>
|
||||||
</Button>
|
<Button variant="secondary" className="tag-list-button">
|
||||||
|
<Link
|
||||||
|
to={NavUtils.makeTagScenesUrl(tag)}
|
||||||
|
className="tag-list-anchor"
|
||||||
|
>
|
||||||
|
Scenes: <FormattedNumber value={tag.scene_count ?? 0} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" className="tag-list-button">
|
||||||
|
<Link
|
||||||
|
to={NavUtils.makeTagSceneMarkersUrl(tag)}
|
||||||
|
className="tag-list-anchor"
|
||||||
|
>
|
||||||
|
Markers:{" "}
|
||||||
|
<FormattedNumber value={tag.scene_marker_count ?? 0} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<span className="tag-list-count">
|
||||||
|
Total:{" "}
|
||||||
|
<FormattedNumber
|
||||||
|
value={(tag.scene_count || 0) + (tag.scene_marker_count || 0)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
|
||||||
|
<Icon icon="trash-alt" color="danger" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
<Modal
|
return (
|
||||||
show={!!editingTag}
|
<div className="col col-sm-8 m-auto">
|
||||||
header={editingTag && editingTag.id ? "Edit Tag" : "New Tag"}
|
{tagElements}
|
||||||
onHide={() => setEditingTag(null)}
|
{deleteAlert}
|
||||||
accept={{
|
</div>
|
||||||
onClick: onEdit,
|
);
|
||||||
variant: "danger",
|
}
|
||||||
text: editingTag?.id ? "Update" : "Create",
|
if (filter.displayMode === DisplayMode.Wall) {
|
||||||
}}
|
return <h1>TODO</h1>;
|
||||||
>
|
}
|
||||||
<Form.Group controlId="tag-name">
|
}
|
||||||
<Form.Label>Name</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
onChange={(newValue: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setName(newValue.currentTarget.value)
|
|
||||||
}
|
|
||||||
defaultValue={(editingTag && editingTag.name) || ""}
|
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{tagElements}
|
return listData.template;
|
||||||
{deleteAlert}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
13
ui/v2.5/src/components/Tags/Tags.tsx
Normal file
13
ui/v2.5/src/components/Tags/Tags.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Route, Switch } from "react-router-dom";
|
||||||
|
import { Tag } from "./TagDetails/Tag";
|
||||||
|
import { TagList } from "./TagList";
|
||||||
|
|
||||||
|
const Tags = () => (
|
||||||
|
<Switch>
|
||||||
|
<Route exact path="/tags" component={TagList} />
|
||||||
|
<Route path="/tags/:id" component={Tag} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Tags;
|
||||||
@@ -18,3 +18,21 @@
|
|||||||
min-width: 6rem;
|
min-width: 6rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-card {
|
||||||
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
&-image {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-details {
|
||||||
|
.logo {
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
max-height: 50vh;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
height: inherit;
|
height: inherit;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
min-height: 210px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +37,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
color: $text-color;
|
color: $text-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 1.5rem;
|
font-size: 1vw;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
font-size: 6vw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-preview {
|
&-preview {
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ export const useFindPerformers = (filter: ListFilterModel) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const useFindTags = (filter: ListFilterModel) =>
|
||||||
|
GQL.useFindTagsQuery({
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter(),
|
||||||
|
tag_filter: filter.makeTagFilter(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const queryFindPerformers = (filter: ListFilterModel) =>
|
export const queryFindPerformers = (filter: ListFilterModel) =>
|
||||||
client.query<GQL.FindPerformersQuery>({
|
client.query<GQL.FindPerformersQuery>({
|
||||||
query: GQL.FindPerformersDocument,
|
query: GQL.FindPerformersDocument,
|
||||||
@@ -120,6 +128,10 @@ export const useFindMovie = (id: string) => {
|
|||||||
const skip = id === "new";
|
const skip = id === "new";
|
||||||
return GQL.useFindMovieQuery({ variables: { id }, skip });
|
return GQL.useFindMovieQuery({ variables: { id }, skip });
|
||||||
};
|
};
|
||||||
|
export const useFindTag = (id: string) => {
|
||||||
|
const skip = id === "new";
|
||||||
|
return GQL.useFindTagQuery({ variables: { id }, skip });
|
||||||
|
};
|
||||||
|
|
||||||
// TODO - scene marker manipulation functions are handled differently
|
// TODO - scene marker manipulation functions are handled differently
|
||||||
export const sceneMarkerMutationImpactedQueries = [
|
export const sceneMarkerMutationImpactedQueries = [
|
||||||
|
|||||||
@@ -153,3 +153,11 @@
|
|||||||
|-------------------|--------|
|
|-------------------|--------|
|
||||||
| `n` | New Tag |
|
| `n` | New Tag |
|
||||||
|
|
||||||
|
## Tag Page shortcuts
|
||||||
|
|
||||||
|
| Keyboard sequence | Action |
|
||||||
|
|-------------------|--------|
|
||||||
|
| `e` | Edit Tag |
|
||||||
|
| `s s` | Save Tag |
|
||||||
|
| `d d` | Delete Tag |
|
||||||
|
| `Ctrl + v` | Paste Tag image |
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
FindPerformersQueryResult,
|
FindPerformersQueryResult,
|
||||||
FindMoviesQueryResult,
|
FindMoviesQueryResult,
|
||||||
MovieDataFragment,
|
MovieDataFragment,
|
||||||
|
FindTagsQueryResult,
|
||||||
|
TagDataFragment,
|
||||||
} from "src/core/generated-graphql";
|
} from "src/core/generated-graphql";
|
||||||
import {
|
import {
|
||||||
useInterfaceLocalForage,
|
useInterfaceLocalForage,
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
useFindStudios,
|
useFindStudios,
|
||||||
useFindGalleries,
|
useFindGalleries,
|
||||||
useFindPerformers,
|
useFindPerformers,
|
||||||
|
useFindTags,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { FilterMode } from "src/models/list-filter/types";
|
import { FilterMode } from "src/models/list-filter/types";
|
||||||
@@ -54,6 +57,7 @@ interface IListHookOptions<T, E> {
|
|||||||
subComponent?: boolean;
|
subComponent?: boolean;
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
zoomable?: boolean;
|
zoomable?: boolean;
|
||||||
|
defaultZoomIndex?: number;
|
||||||
otherOperations?: IListHookOperation<T>[];
|
otherOperations?: IListHookOperation<T>[];
|
||||||
renderContent: (
|
renderContent: (
|
||||||
result: T,
|
result: T,
|
||||||
@@ -111,7 +115,9 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
|
|||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [lastClickedId, setLastClickedId] = useState<string | undefined>();
|
const [lastClickedId, setLastClickedId] = useState<string | undefined>();
|
||||||
const [zoomIndex, setZoomIndex] = useState<number>(1);
|
const [zoomIndex, setZoomIndex] = useState<number>(
|
||||||
|
options.defaultZoomIndex ?? 1
|
||||||
|
);
|
||||||
|
|
||||||
const result = options.useData(getFilter());
|
const result = options.useData(getFilter());
|
||||||
const totalCount = options.getCount(result);
|
const totalCount = options.getCount(result);
|
||||||
@@ -573,3 +579,18 @@ export const useMoviesList = (
|
|||||||
selectedIds: Set<string>
|
selectedIds: Set<string>
|
||||||
) => getSelectedData(result?.data?.findMovies?.movies ?? [], selectedIds),
|
) => getSelectedData(result?.data?.findMovies?.movies ?? [], selectedIds),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const useTagsList = (
|
||||||
|
props: IListHookOptions<FindTagsQueryResult, TagDataFragment>
|
||||||
|
) =>
|
||||||
|
useList<FindTagsQueryResult, TagDataFragment>({
|
||||||
|
...props,
|
||||||
|
filterMode: FilterMode.Tags,
|
||||||
|
useData: useFindTags,
|
||||||
|
getData: (result: FindTagsQueryResult) =>
|
||||||
|
result?.data?.findTags?.tags ?? [],
|
||||||
|
getCount: (result: FindTagsQueryResult) =>
|
||||||
|
result?.data?.findTags?.count ?? 0,
|
||||||
|
getSelectedData: (result: FindTagsQueryResult, selectedIds: Set<string>) =>
|
||||||
|
getSelectedData(result?.data?.findTags?.tags ?? [], selectedIds),
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ textarea.text-input {
|
|||||||
width: 240px;
|
width: 240px;
|
||||||
|
|
||||||
.scene-card-video,
|
.scene-card-video,
|
||||||
.gallery-card-image {
|
.gallery-card-image,
|
||||||
|
.tag-card-image {
|
||||||
max-height: 180px;
|
max-height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +114,8 @@ textarea.text-input {
|
|||||||
width: 320px;
|
width: 320px;
|
||||||
|
|
||||||
.scene-card-video,
|
.scene-card-video,
|
||||||
.gallery-card-image {
|
.gallery-card-image,
|
||||||
|
.tag-card-image {
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +128,8 @@ textarea.text-input {
|
|||||||
width: 480px;
|
width: 480px;
|
||||||
|
|
||||||
.scene-card-video,
|
.scene-card-video,
|
||||||
.gallery-card-image {
|
.gallery-card-image,
|
||||||
|
.tag-card-image {
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +142,8 @@ textarea.text-input {
|
|||||||
width: 640px;
|
width: 640px;
|
||||||
|
|
||||||
.scene-card-video,
|
.scene-card-video,
|
||||||
.gallery-card-image {
|
.gallery-card-image,
|
||||||
|
.tag-card-image {
|
||||||
max-height: 480px;
|
max-height: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +154,8 @@ textarea.text-input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scene-card-video,
|
.scene-card-video,
|
||||||
.gallery-card-image {
|
.gallery-card-image,
|
||||||
|
.tag-card-image {
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type CriterionType =
|
|||||||
| "sceneIsMissing"
|
| "sceneIsMissing"
|
||||||
| "performerIsMissing"
|
| "performerIsMissing"
|
||||||
| "galleryIsMissing"
|
| "galleryIsMissing"
|
||||||
|
| "tagIsMissing"
|
||||||
| "tags"
|
| "tags"
|
||||||
| "sceneTags"
|
| "sceneTags"
|
||||||
| "performers"
|
| "performers"
|
||||||
@@ -33,7 +34,9 @@ export type CriterionType =
|
|||||||
| "piercings"
|
| "piercings"
|
||||||
| "aliases"
|
| "aliases"
|
||||||
| "gender"
|
| "gender"
|
||||||
| "parent_studios";
|
| "parent_studios"
|
||||||
|
| "scene_count"
|
||||||
|
| "marker_count";
|
||||||
|
|
||||||
type Option = string | number | IOptionType;
|
type Option = string | number | IOptionType;
|
||||||
export type CriterionValue = string | number | ILabeledId[];
|
export type CriterionValue = string | number | ILabeledId[];
|
||||||
@@ -56,10 +59,9 @@ export abstract class Criterion {
|
|||||||
case "hasMarkers":
|
case "hasMarkers":
|
||||||
return "Has Markers";
|
return "Has Markers";
|
||||||
case "sceneIsMissing":
|
case "sceneIsMissing":
|
||||||
return "Is Missing";
|
|
||||||
case "performerIsMissing":
|
case "performerIsMissing":
|
||||||
return "Is Missing";
|
|
||||||
case "galleryIsMissing":
|
case "galleryIsMissing":
|
||||||
|
case "tagIsMissing":
|
||||||
return "Is Missing";
|
return "Is Missing";
|
||||||
case "tags":
|
case "tags":
|
||||||
return "Tags";
|
return "Tags";
|
||||||
@@ -99,6 +101,10 @@ export abstract class Criterion {
|
|||||||
return "Gender";
|
return "Gender";
|
||||||
case "parent_studios":
|
case "parent_studios":
|
||||||
return "Parent Studios";
|
return "Parent Studios";
|
||||||
|
case "scene_count":
|
||||||
|
return "Scene Count";
|
||||||
|
case "marker_count":
|
||||||
|
return "Marker Count";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,3 +63,13 @@ export class GalleryIsMissingCriterionOption implements ICriterionOption {
|
|||||||
public label: string = Criterion.getLabel("galleryIsMissing");
|
public label: string = Criterion.getLabel("galleryIsMissing");
|
||||||
public value: CriterionType = "galleryIsMissing";
|
public value: CriterionType = "galleryIsMissing";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TagIsMissingCriterion extends IsMissingCriterion {
|
||||||
|
public type: CriterionType = "tagIsMissing";
|
||||||
|
public options: string[] = ["image"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TagIsMissingCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("tagIsMissing");
|
||||||
|
public value: CriterionType = "tagIsMissing";
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
PerformerIsMissingCriterion,
|
PerformerIsMissingCriterion,
|
||||||
SceneIsMissingCriterion,
|
SceneIsMissingCriterion,
|
||||||
GalleryIsMissingCriterion,
|
GalleryIsMissingCriterion,
|
||||||
|
TagIsMissingCriterion,
|
||||||
} from "./is-missing";
|
} from "./is-missing";
|
||||||
import { NoneCriterion } from "./none";
|
import { NoneCriterion } from "./none";
|
||||||
import { PerformersCriterion } from "./performers";
|
import { PerformersCriterion } from "./performers";
|
||||||
@@ -30,6 +31,8 @@ export function makeCriteria(type: CriterionType = "none") {
|
|||||||
case "rating":
|
case "rating":
|
||||||
return new RatingCriterion();
|
return new RatingCriterion();
|
||||||
case "o_counter":
|
case "o_counter":
|
||||||
|
case "scene_count":
|
||||||
|
case "marker_count":
|
||||||
return new NumberCriterion(type, type);
|
return new NumberCriterion(type, type);
|
||||||
case "resolution":
|
case "resolution":
|
||||||
return new ResolutionCriterion();
|
return new ResolutionCriterion();
|
||||||
@@ -45,6 +48,8 @@ export function makeCriteria(type: CriterionType = "none") {
|
|||||||
return new PerformerIsMissingCriterion();
|
return new PerformerIsMissingCriterion();
|
||||||
case "galleryIsMissing":
|
case "galleryIsMissing":
|
||||||
return new GalleryIsMissingCriterion();
|
return new GalleryIsMissingCriterion();
|
||||||
|
case "tagIsMissing":
|
||||||
|
return new TagIsMissingCriterion();
|
||||||
case "tags":
|
case "tags":
|
||||||
return new TagsCriterion("tags");
|
return new TagsCriterion("tags");
|
||||||
case "sceneTags":
|
case "sceneTags":
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
MovieFilterType,
|
MovieFilterType,
|
||||||
StudioFilterType,
|
StudioFilterType,
|
||||||
GalleryFilterType,
|
GalleryFilterType,
|
||||||
|
TagFilterType,
|
||||||
} from "src/core/generated-graphql";
|
} from "src/core/generated-graphql";
|
||||||
import { stringToGender } from "src/core/StashService";
|
import { stringToGender } from "src/core/StashService";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
PerformerIsMissingCriterionOption,
|
PerformerIsMissingCriterionOption,
|
||||||
SceneIsMissingCriterionOption,
|
SceneIsMissingCriterionOption,
|
||||||
GalleryIsMissingCriterionOption,
|
GalleryIsMissingCriterionOption,
|
||||||
|
TagIsMissingCriterionOption,
|
||||||
} from "./criteria/is-missing";
|
} from "./criteria/is-missing";
|
||||||
import { NoneCriterionOption } from "./criteria/none";
|
import { NoneCriterionOption } from "./criteria/none";
|
||||||
import {
|
import {
|
||||||
@@ -207,6 +209,17 @@ export class ListFilterModel {
|
|||||||
new PerformersCriterionOption(),
|
new PerformersCriterionOption(),
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
case FilterMode.Tags:
|
||||||
|
this.sortBy = "name";
|
||||||
|
this.sortByOptions = ["name", "scenes_count", "scene_markers_count"];
|
||||||
|
this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
|
||||||
|
this.criterionOptions = [
|
||||||
|
new NoneCriterionOption(),
|
||||||
|
new TagIsMissingCriterionOption(),
|
||||||
|
ListFilterModel.createCriterionOption("scene_count"),
|
||||||
|
ListFilterModel.createCriterionOption("marker_count"),
|
||||||
|
];
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
this.sortByOptions = [];
|
this.sortByOptions = [];
|
||||||
this.displayModeOptions = [];
|
this.displayModeOptions = [];
|
||||||
@@ -623,4 +636,34 @@ export class ListFilterModel {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public makeTagFilter(): TagFilterType {
|
||||||
|
const result: TagFilterType = {};
|
||||||
|
this.criteria.forEach((criterion) => {
|
||||||
|
switch (criterion.type) {
|
||||||
|
case "tagIsMissing":
|
||||||
|
result.is_missing = (criterion as IsMissingCriterion).value;
|
||||||
|
break;
|
||||||
|
case "scene_count": {
|
||||||
|
const countCrit = criterion as NumberCriterion;
|
||||||
|
result.scene_count = {
|
||||||
|
value: countCrit.value,
|
||||||
|
modifier: countCrit.modifier,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "marker_count": {
|
||||||
|
const countCrit = criterion as NumberCriterion;
|
||||||
|
result.marker_count = {
|
||||||
|
value: countCrit.value,
|
||||||
|
modifier: countCrit.modifier,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// no default
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export enum FilterMode {
|
|||||||
Galleries,
|
Galleries,
|
||||||
SceneMarkers,
|
SceneMarkers,
|
||||||
Movies,
|
Movies,
|
||||||
|
Tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ILabeledId {
|
export interface ILabeledId {
|
||||||
|
|||||||
Reference in New Issue
Block a user