mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +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 {
|
||||
id
|
||||
name
|
||||
image_path
|
||||
scene_count
|
||||
scene_marker_count
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mutation TagCreate($name: String!) {
|
||||
tagCreate(input: { name: $name }) {
|
||||
mutation TagCreate($name: String!, $image: String) {
|
||||
tagCreate(input: { name: $name, image: $image }) {
|
||||
...TagData
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ mutation TagDestroy($id: ID!) {
|
||||
tagDestroy(input: { id: $id })
|
||||
}
|
||||
|
||||
mutation TagUpdate($id: ID!, $name: String!) {
|
||||
tagUpdate(input: { id: $id, name: $name }) {
|
||||
mutation TagUpdate($id: ID!, $name: String!, $image: String) {
|
||||
tagUpdate(input: { id: $id, name: $name, image: $image }) {
|
||||
...TagData
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
query FindTag($id: ID!) {
|
||||
findTag(id: $id) {
|
||||
...TagData
|
||||
}
|
||||
}
|
||||
|
||||
query MarkerStrings($q: String, $sort: String) {
|
||||
markerStrings(q: $q, sort: $sort) {
|
||||
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!
|
||||
|
||||
findTag(id: ID!): Tag
|
||||
findTags(tag_filter: TagFilterType, filter: FindFilterType): FindTagsResultType!
|
||||
|
||||
"""Retrieve random scene markers for the wall"""
|
||||
markerWall(q: String): [SceneMarker!]!
|
||||
|
||||
@@ -101,6 +101,17 @@ input GalleryFilterType {
|
||||
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 {
|
||||
"""="""
|
||||
EQUALS,
|
||||
|
||||
@@ -2,19 +2,31 @@ type Tag {
|
||||
id: ID!
|
||||
name: String!
|
||||
|
||||
image_path: String # Resolver
|
||||
scene_count: Int # Resolver
|
||||
scene_marker_count: Int # Resolver
|
||||
}
|
||||
|
||||
input TagCreateInput {
|
||||
name: String!
|
||||
|
||||
"""This should be base64 encoded"""
|
||||
image: String
|
||||
}
|
||||
|
||||
input TagUpdateInput {
|
||||
id: ID!
|
||||
name: String!
|
||||
|
||||
"""This should be base64 encoded"""
|
||||
image: String
|
||||
}
|
||||
|
||||
input TagDestroyInput {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type FindTagsResultType {
|
||||
count: Int!
|
||||
tags: [Tag!]!
|
||||
}
|
||||
@@ -11,4 +11,5 @@ const (
|
||||
studioKey key = 3
|
||||
movieKey key = 4
|
||||
ContextUser key = 5
|
||||
tagKey key = 6
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/stashapp/stash/pkg/api/urlbuilders"
|
||||
"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)
|
||||
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 (
|
||||
"context"
|
||||
"github.com/stashapp/stash/pkg/database"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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) {
|
||||
@@ -17,15 +21,41 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
tx.Rollback()
|
||||
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
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
@@ -43,15 +73,54 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
|
||||
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
|
||||
tx := database.DB.MustBeginTx(ctx, nil)
|
||||
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)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
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
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -2,8 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
qb := models.NewTagQueryBuilder()
|
||||
return qb.All()
|
||||
|
||||
@@ -2,14 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type studioRoutes struct{}
|
||||
@@ -29,23 +27,7 @@ func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) {
|
||||
studio := r.Context().Value(studioKey).(*models.Studio)
|
||||
qb := models.NewStudioQueryBuilder()
|
||||
image, _ := qb.GetStudioImage(studio.ID, nil)
|
||||
|
||||
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)
|
||||
utils.ServeImage(image, w, r)
|
||||
}
|
||||
|
||||
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("/studio", studioRoutes{}.Routes())
|
||||
r.Mount("/movie", movieRoutes{}.Routes())
|
||||
r.Mount("/tag", tagRoutes{}.Routes())
|
||||
|
||||
r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) {
|
||||
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 dbPath string
|
||||
var appSchemaVersion uint = 10
|
||||
var appSchemaVersion uint = 11
|
||||
var databaseSchemaVersion uint
|
||||
|
||||
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
|
||||
} else if level == "Error" {
|
||||
ret = logrus.ErrorLevel
|
||||
} else if level == "Trace" {
|
||||
ret = logrus.TraceLevel
|
||||
}
|
||||
|
||||
return ret
|
||||
@@ -178,6 +180,15 @@ func Trace(args ...interface{}) {
|
||||
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{}) {
|
||||
logger.Debug(args...)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return jsonschema.LoadMovieFile(instance.Paths.JSON.MovieJSONPath(checksum))
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ package jsonschema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/json-iterator/go"
|
||||
"os"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
)
|
||||
|
||||
type NameMapping struct {
|
||||
@@ -17,6 +18,7 @@ type PathMapping struct {
|
||||
}
|
||||
|
||||
type Mappings struct {
|
||||
Tags []NameMapping `json:"tags"`
|
||||
Performers []NameMapping `json:"performers"`
|
||||
Studios []NameMapping `json:"studios"`
|
||||
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
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/stashapp/stash/pkg/manager/config"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type jsonPaths struct {
|
||||
@@ -16,6 +17,7 @@ type jsonPaths struct {
|
||||
Scenes string
|
||||
Galleries string
|
||||
Studios string
|
||||
Tags string
|
||||
Movies string
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@ func newJSONPaths() *jsonPaths {
|
||||
jp.Galleries = filepath.Join(config.GetMetadataPath(), "galleries")
|
||||
jp.Studios = filepath.Join(config.GetMetadataPath(), "studios")
|
||||
jp.Movies = filepath.Join(config.GetMetadataPath(), "movies")
|
||||
jp.Tags = filepath.Join(config.GetMetadataPath(), "tags")
|
||||
return &jp
|
||||
}
|
||||
|
||||
@@ -45,6 +48,7 @@ func EnsureJSONDirs() {
|
||||
utils.EnsureDir(jsonPaths.Performers)
|
||||
utils.EnsureDir(jsonPaths.Studios)
|
||||
utils.EnsureDir(jsonPaths.Movies)
|
||||
utils.EnsureDir(jsonPaths.Tags)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func (jp *jsonPaths) TagJSONPath(checksum string) string {
|
||||
return filepath.Join(jp.Tags, checksum+".json")
|
||||
}
|
||||
|
||||
func (jp *jsonPaths) MovieJSONPath(checksum string) string {
|
||||
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.ExportStudios(ctx, workerCount)
|
||||
t.ExportMovies(ctx, workerCount)
|
||||
t.ExportTags(ctx, workerCount)
|
||||
|
||||
if err := instance.JSON.saveMappings(t.Mappings); err != nil {
|
||||
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())
|
||||
continue
|
||||
}
|
||||
if sceneMarker.Title == "" || sceneMarker.Seconds == 0 || primaryTag.Name == "" {
|
||||
if sceneMarker.Seconds == 0 || primaryTag.Name == "" {
|
||||
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) {
|
||||
var moviesWg sync.WaitGroup
|
||||
|
||||
|
||||
@@ -45,11 +45,11 @@ func (t *ImportTask) Start(wg *sync.WaitGroup) {
|
||||
|
||||
ctx := context.TODO()
|
||||
|
||||
t.ImportTags(ctx)
|
||||
t.ImportPerformers(ctx)
|
||||
t.ImportStudios(ctx)
|
||||
t.ImportMovies(ctx)
|
||||
t.ImportGalleries(ctx)
|
||||
t.ImportTags(ctx)
|
||||
|
||||
t.ImportScrapedItems(ctx)
|
||||
t.ImportScenes(ctx)
|
||||
@@ -415,61 +415,52 @@ func (t *ImportTask) ImportTags(ctx context.Context) {
|
||||
tx := database.DB.MustBeginTx(ctx, nil)
|
||||
qb := models.NewTagQueryBuilder()
|
||||
|
||||
var tagNames []string
|
||||
|
||||
for i, mappingJSON := range t.Mappings.Scenes {
|
||||
for i, mappingJSON := range t.Mappings.Tags {
|
||||
index := i + 1
|
||||
if mappingJSON.Checksum == "" || mappingJSON.Path == "" {
|
||||
_ = tx.Rollback()
|
||||
logger.Warn("[tags] scene mapping without checksum or path: ", mappingJSON)
|
||||
tagJSON, err := instance.JSON.getTag(mappingJSON.Checksum)
|
||||
if err != nil {
|
||||
logger.Errorf("[tags] failed to read json: %s", err.Error())
|
||||
continue
|
||||
}
|
||||
if mappingJSON.Checksum == "" || mappingJSON.Name == "" || tagJSON == nil {
|
||||
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)
|
||||
if err != nil {
|
||||
logger.Infof("[tags] <%s> json parse failure: %s", mappingJSON.Checksum, err.Error())
|
||||
}
|
||||
// Return early if we are missing a json file.
|
||||
if sceneJSON == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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...)
|
||||
// Process the base 64 encoded image string
|
||||
var imageData []byte
|
||||
if len(tagJSON.Image) > 0 {
|
||||
_, imageData, err = utils.ProcessBase64Image(tagJSON.Image)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
logger.Errorf("[tags] <%s> invalid image: %s", mappingJSON.Checksum, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uniqueTagNames := t.getUnique(tagNames)
|
||||
for _, tagName := range uniqueTagNames {
|
||||
currentTime := time.Now()
|
||||
// Populate a new tag from the input
|
||||
newTag := models.Tag{
|
||||
Name: tagName,
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
Name: tagJSON.Name,
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: t.getTimeFromJSONTime(tagJSON.CreatedAt)},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: t.getTimeFromJSONTime(tagJSON.UpdatedAt)},
|
||||
}
|
||||
|
||||
_, err := qb.Create(newTag, tx)
|
||||
createdTag, err := qb.Create(newTag, tx)
|
||||
if err != nil {
|
||||
_ = 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
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
@@ -6,3 +6,132 @@ type Tag struct {
|
||||
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_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) {
|
||||
t.Helper()
|
||||
assert := assert.New(t)
|
||||
if criterion.Modifier == models.CriterionModifierIsNull {
|
||||
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_"
|
||||
|
||||
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")
|
||||
return " ORDER BY COUNT(distinct " + colName + ") " + direction
|
||||
} else if strings.Compare(sort, "filesize") == 0 {
|
||||
@@ -325,6 +325,9 @@ func executeFindQuery(tableName string, body string, args []interface{}, sortAnd
|
||||
panic(idsErr)
|
||||
}
|
||||
|
||||
// Perform query and fetch result
|
||||
logger.Tracef("SQL: %s, args: %v", idsQuery, args)
|
||||
|
||||
return idsResult, countResult
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/stashapp/stash/pkg/database"
|
||||
)
|
||||
|
||||
const tagTable = "tags"
|
||||
|
||||
type TagQueryBuilder struct{}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
findFilter = &FindFilterType{}
|
||||
}
|
||||
|
||||
var whereClauses []string
|
||||
var havingClauses []string
|
||||
var args []interface{}
|
||||
body := selectDistinctIDs("tags")
|
||||
query := queryBuilder{
|
||||
tableName: tagTable,
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
searchColumns := []string{"tags.name"}
|
||||
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
|
||||
whereClauses = append(whereClauses, clause)
|
||||
args = append(args, thisArgs...)
|
||||
query.addWhere(clause)
|
||||
query.addArg(thisArgs...)
|
||||
}
|
||||
|
||||
sortAndPagination := qb.getTagSort(findFilter) + getPagination(findFilter)
|
||||
idsResult, countResult := executeFindQuery("tags", body, args, sortAndPagination, whereClauses, havingClauses)
|
||||
if isMissingFilter := tagFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||
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
|
||||
for _, id := range idsResult {
|
||||
@@ -225,3 +262,36 @@ func (qb *TagQueryBuilder) queryTags(query string, args []interface{}, tx *sqlx.
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/database"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"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 Update
|
||||
// TODO Destroy
|
||||
|
||||
@@ -26,7 +26,7 @@ const moviesNameCase = 2
|
||||
const moviesNameNoCase = 1
|
||||
const totalGalleries = 2
|
||||
const tagsNameNoCase = 2
|
||||
const tagsNameCase = 5
|
||||
const tagsNameCase = 6
|
||||
const studiosNameCase = 4
|
||||
const studiosNameNoCase = 1
|
||||
|
||||
@@ -73,10 +73,11 @@ const tagIdx1WithScene = 1
|
||||
const tagIdx2WithScene = 2
|
||||
const tagIdxWithPrimaryMarker = 3
|
||||
const tagIdxWithMarker = 4
|
||||
const tagIdxWithImage = 5
|
||||
|
||||
// tags with dup names start from the end
|
||||
const tagIdx1WithDupName = 5
|
||||
const tagIdxWithDupName = 6
|
||||
const tagIdx1WithDupName = 6
|
||||
const tagIdxWithDupName = 7
|
||||
|
||||
const studioIdxWithScene = 0
|
||||
const studioIdxWithMovie = 1
|
||||
@@ -162,6 +163,11 @@ func populateDB() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := addTagImage(tx, tagIdxWithImage); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := createStudios(tx, studiosNameCase, studiosNameNoCase); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
@@ -404,6 +410,22 @@ func getTagStringValue(index int, field string) string {
|
||||
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
|
||||
func createTags(tx *sqlx.Tx, n int, o int) error {
|
||||
tqb := models.NewTagQueryBuilder()
|
||||
@@ -433,7 +455,6 @@ func createTags(tx *sqlx.Tx, n int, o int) error {
|
||||
|
||||
tagIDs = append(tagIDs, created.ID)
|
||||
tagNames = append(tagNames, created.Name)
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -630,3 +651,9 @@ func linkStudioParent(tx *sqlx.Tx, parentIndex, childIndex int) error {
|
||||
|
||||
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)
|
||||
_, err := w.Write(image)
|
||||
return err
|
||||
|
||||
@@ -20,9 +20,9 @@ import Scenes from "./components/Scenes/Scenes";
|
||||
import { Settings } from "./components/Settings/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
import Studios from "./components/Studios/Studios";
|
||||
import { TagList } from "./components/Tags/TagList";
|
||||
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
||||
import Movies from "./components/Movies/Movies";
|
||||
import Tags from "./components/Tags/Tags";
|
||||
|
||||
// Set fontawesome/free-solid-svg as default fontawesome icons
|
||||
library.add(fas);
|
||||
@@ -51,7 +51,7 @@ export const App: React.FC = () => {
|
||||
<Route path="/scenes" component={Scenes} />
|
||||
<Route path="/galleries" component={Galleries} />
|
||||
<Route path="/performers" component={Performers} />
|
||||
<Route path="/tags" component={TagList} />
|
||||
<Route path="/tags" component={Tags} />
|
||||
<Route path="/studios" component={Studios} />
|
||||
<Route path="/movies" component={Movies} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from "react-markdown";
|
||||
|
||||
const markup = `
|
||||
### ✨ New Features
|
||||
* Add tag thumbnails, tags grid view and tag page.
|
||||
* Add post-scrape dialog.
|
||||
* Add various keyboard shortcuts (see manual).
|
||||
* Support deleting multiple scenes.
|
||||
|
||||
@@ -135,6 +135,8 @@ export const MainNavbar: React.FC = () => {
|
||||
? "/studios/new"
|
||||
: location.pathname === "/movies"
|
||||
? "/movies/new"
|
||||
: location.pathname === "/tags"
|
||||
? "/tags/new"
|
||||
: null;
|
||||
const newButton =
|
||||
newPath === null ? (
|
||||
|
||||
@@ -9,7 +9,15 @@ import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
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 otherOperations = [
|
||||
{
|
||||
@@ -34,6 +42,8 @@ export const SceneMarkerList: React.FC = () => {
|
||||
const listData = useSceneMarkersList({
|
||||
otherOperations,
|
||||
renderContent,
|
||||
subComponent,
|
||||
filterHook,
|
||||
addKeybinds,
|
||||
});
|
||||
|
||||
|
||||
@@ -447,7 +447,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
||||
}
|
||||
value={logLevel}
|
||||
>
|
||||
{["Debug", "Info", "Warning", "Error"].map((o) => (
|
||||
{["Trace", "Debug", "Info", "Warning", "Error"].map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
|
||||
@@ -66,7 +66,7 @@ class LogEntry {
|
||||
// maximum number of log entries to display. Subsequent entries will truncate
|
||||
// the list, dropping off the oldest entries first.
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
const logLevels = ["Debug", "Info", "Warning", "Error"];
|
||||
const logLevels = ["Trace", "Debug", "Info", "Warning", "Error"];
|
||||
|
||||
const logReducer = (existingEntries: LogEntry[], newEntries: LogEntry[]) => [
|
||||
...newEntries.reverse(),
|
||||
@@ -96,7 +96,7 @@ export const SettingsLogsPanel: React.FC = () => {
|
||||
);
|
||||
|
||||
function filterByLogLevel(logEntry: LogEntry) {
|
||||
if (logLevel === "Debug") return true;
|
||||
if (logLevel === "Trace") return true;
|
||||
|
||||
const logLevelIndex = logLevels.indexOf(logLevel);
|
||||
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 { 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 { FormattedNumber } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
mutateMetadataAutoTag,
|
||||
useAllTags,
|
||||
useTagUpdate,
|
||||
useTagCreate,
|
||||
useTagDestroy,
|
||||
} from "src/core/StashService";
|
||||
import { NavUtils } from "src/utils";
|
||||
import { Icon, Modal, LoadingIndicator } from "src/components/Shared";
|
||||
import { mutateMetadataAutoTag, useTagDestroy } from "src/core/StashService";
|
||||
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();
|
||||
// Editing / New state
|
||||
const [name, setName] = useState("");
|
||||
const [editingTag, setEditingTag] = useState<Partial<
|
||||
GQL.TagDataFragment
|
||||
> | null>(null);
|
||||
const [deletingTag, setDeletingTag] = useState<Partial<
|
||||
GQL.TagDataFragment
|
||||
> | 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);
|
||||
|
||||
function getTagInput() {
|
||||
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name };
|
||||
if (editingTag)
|
||||
(tagInput as Partial<GQL.TagUpdateInput>).id = editingTag.id;
|
||||
return tagInput;
|
||||
}
|
||||
const listData = useTagsList({
|
||||
renderContent,
|
||||
filterHook,
|
||||
zoomable: true,
|
||||
defaultZoomIndex: 0,
|
||||
});
|
||||
|
||||
function getDeleteTagInput() {
|
||||
const tagInput: Partial<GQL.TagDestroyInput> = {};
|
||||
@@ -45,21 +40,6 @@ export const TagList: React.FC = () => {
|
||||
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) {
|
||||
if (!tag) return;
|
||||
try {
|
||||
@@ -94,86 +74,79 @@ export const TagList: React.FC = () => {
|
||||
</Modal>
|
||||
);
|
||||
|
||||
if (!data?.allTags) return <LoadingIndicator />;
|
||||
if (error) return <div>{error.message}</div>;
|
||||
function renderContent(
|
||||
result: FindTagsQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>,
|
||||
zoomIndex: number
|
||||
) {
|
||||
if (!result.data?.findTags) return;
|
||||
|
||||
const tagElements = data.allTags.map((tag) => {
|
||||
return (
|
||||
<div key={tag.id} className="tag-list-row row">
|
||||
<Button variant="link" onClick={() => setEditingTag(tag)}>
|
||||
{tag.name}
|
||||
</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>
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="row px-xl-5 justify-content-center">
|
||||
{result.data.findTags.tags.map((tag) => (
|
||||
<TagCard key={tag.id} tag={tag} zoomIndex={zoomIndex} />
|
||||
))}
|
||||
</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="col col-sm-8 m-auto">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-2"
|
||||
onClick={() => setEditingTag({})}
|
||||
>
|
||||
New Tag
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
<Modal
|
||||
show={!!editingTag}
|
||||
header={editingTag && editingTag.id ? "Edit Tag" : "New Tag"}
|
||||
onHide={() => setEditingTag(null)}
|
||||
accept={{
|
||||
onClick: onEdit,
|
||||
variant: "danger",
|
||||
text: editingTag?.id ? "Update" : "Create",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
return (
|
||||
<div className="col col-sm-8 m-auto">
|
||||
{tagElements}
|
||||
{deleteAlert}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <h1>TODO</h1>;
|
||||
}
|
||||
}
|
||||
|
||||
{tagElements}
|
||||
{deleteAlert}
|
||||
</div>
|
||||
);
|
||||
return listData.template;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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) {
|
||||
height: inherit;
|
||||
max-width: 100%;
|
||||
min-height: 210px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -36,8 +37,13 @@
|
||||
align-items: center;
|
||||
color: $text-color;
|
||||
display: flex;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1vw;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
font-size: 6vw;
|
||||
}
|
||||
}
|
||||
|
||||
&-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) =>
|
||||
client.query<GQL.FindPerformersQuery>({
|
||||
query: GQL.FindPerformersDocument,
|
||||
@@ -120,6 +128,10 @@ export const useFindMovie = (id: string) => {
|
||||
const skip = id === "new";
|
||||
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
|
||||
export const sceneMarkerMutationImpactedQueries = [
|
||||
|
||||
@@ -153,3 +153,11 @@
|
||||
|-------------------|--------|
|
||||
| `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,
|
||||
FindMoviesQueryResult,
|
||||
MovieDataFragment,
|
||||
FindTagsQueryResult,
|
||||
TagDataFragment,
|
||||
} from "src/core/generated-graphql";
|
||||
import {
|
||||
useInterfaceLocalForage,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
useFindStudios,
|
||||
useFindGalleries,
|
||||
useFindPerformers,
|
||||
useFindTags,
|
||||
} from "src/core/StashService";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { FilterMode } from "src/models/list-filter/types";
|
||||
@@ -54,6 +57,7 @@ interface IListHookOptions<T, E> {
|
||||
subComponent?: boolean;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
zoomable?: boolean;
|
||||
defaultZoomIndex?: number;
|
||||
otherOperations?: IListHookOperation<T>[];
|
||||
renderContent: (
|
||||
result: T,
|
||||
@@ -111,7 +115,9 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
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 totalCount = options.getCount(result);
|
||||
@@ -573,3 +579,18 @@ export const useMoviesList = (
|
||||
selectedIds: Set<string>
|
||||
) => 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;
|
||||
|
||||
.scene-card-video,
|
||||
.gallery-card-image {
|
||||
.gallery-card-image,
|
||||
.tag-card-image {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
@@ -113,7 +114,8 @@ textarea.text-input {
|
||||
width: 320px;
|
||||
|
||||
.scene-card-video,
|
||||
.gallery-card-image {
|
||||
.gallery-card-image,
|
||||
.tag-card-image {
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
@@ -126,7 +128,8 @@ textarea.text-input {
|
||||
width: 480px;
|
||||
|
||||
.scene-card-video,
|
||||
.gallery-card-image {
|
||||
.gallery-card-image,
|
||||
.tag-card-image {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
@@ -139,7 +142,8 @@ textarea.text-input {
|
||||
width: 640px;
|
||||
|
||||
.scene-card-video,
|
||||
.gallery-card-image {
|
||||
.gallery-card-image,
|
||||
.tag-card-image {
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
@@ -150,7 +154,8 @@ textarea.text-input {
|
||||
}
|
||||
|
||||
.scene-card-video,
|
||||
.gallery-card-image {
|
||||
.gallery-card-image,
|
||||
.tag-card-image {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type CriterionType =
|
||||
| "sceneIsMissing"
|
||||
| "performerIsMissing"
|
||||
| "galleryIsMissing"
|
||||
| "tagIsMissing"
|
||||
| "tags"
|
||||
| "sceneTags"
|
||||
| "performers"
|
||||
@@ -33,7 +34,9 @@ export type CriterionType =
|
||||
| "piercings"
|
||||
| "aliases"
|
||||
| "gender"
|
||||
| "parent_studios";
|
||||
| "parent_studios"
|
||||
| "scene_count"
|
||||
| "marker_count";
|
||||
|
||||
type Option = string | number | IOptionType;
|
||||
export type CriterionValue = string | number | ILabeledId[];
|
||||
@@ -56,10 +59,9 @@ export abstract class Criterion {
|
||||
case "hasMarkers":
|
||||
return "Has Markers";
|
||||
case "sceneIsMissing":
|
||||
return "Is Missing";
|
||||
case "performerIsMissing":
|
||||
return "Is Missing";
|
||||
case "galleryIsMissing":
|
||||
case "tagIsMissing":
|
||||
return "Is Missing";
|
||||
case "tags":
|
||||
return "Tags";
|
||||
@@ -99,6 +101,10 @@ export abstract class Criterion {
|
||||
return "Gender";
|
||||
case "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 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,
|
||||
SceneIsMissingCriterion,
|
||||
GalleryIsMissingCriterion,
|
||||
TagIsMissingCriterion,
|
||||
} from "./is-missing";
|
||||
import { NoneCriterion } from "./none";
|
||||
import { PerformersCriterion } from "./performers";
|
||||
@@ -30,6 +31,8 @@ export function makeCriteria(type: CriterionType = "none") {
|
||||
case "rating":
|
||||
return new RatingCriterion();
|
||||
case "o_counter":
|
||||
case "scene_count":
|
||||
case "marker_count":
|
||||
return new NumberCriterion(type, type);
|
||||
case "resolution":
|
||||
return new ResolutionCriterion();
|
||||
@@ -45,6 +48,8 @@ export function makeCriteria(type: CriterionType = "none") {
|
||||
return new PerformerIsMissingCriterion();
|
||||
case "galleryIsMissing":
|
||||
return new GalleryIsMissingCriterion();
|
||||
case "tagIsMissing":
|
||||
return new TagIsMissingCriterion();
|
||||
case "tags":
|
||||
return new TagsCriterion("tags");
|
||||
case "sceneTags":
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MovieFilterType,
|
||||
StudioFilterType,
|
||||
GalleryFilterType,
|
||||
TagFilterType,
|
||||
} from "src/core/generated-graphql";
|
||||
import { stringToGender } from "src/core/StashService";
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
PerformerIsMissingCriterionOption,
|
||||
SceneIsMissingCriterionOption,
|
||||
GalleryIsMissingCriterionOption,
|
||||
TagIsMissingCriterionOption,
|
||||
} from "./criteria/is-missing";
|
||||
import { NoneCriterionOption } from "./criteria/none";
|
||||
import {
|
||||
@@ -207,6 +209,17 @@ export class ListFilterModel {
|
||||
new PerformersCriterionOption(),
|
||||
];
|
||||
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:
|
||||
this.sortByOptions = [];
|
||||
this.displayModeOptions = [];
|
||||
@@ -623,4 +636,34 @@ export class ListFilterModel {
|
||||
|
||||
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,
|
||||
SceneMarkers,
|
||||
Movies,
|
||||
Tags,
|
||||
}
|
||||
|
||||
export interface ILabeledId {
|
||||
|
||||
Reference in New Issue
Block a user