diff --git a/graphql/documents/data/tag.graphql b/graphql/documents/data/tag.graphql index f779ad064..650f52a56 100644 --- a/graphql/documents/data/tag.graphql +++ b/graphql/documents/data/tag.graphql @@ -1,6 +1,7 @@ fragment TagData on Tag { id name + image_path scene_count scene_marker_count } diff --git a/graphql/documents/mutations/tag.graphql b/graphql/documents/mutations/tag.graphql index 85e1efcd2..3dcd8f2b3 100644 --- a/graphql/documents/mutations/tag.graphql +++ b/graphql/documents/mutations/tag.graphql @@ -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 } } \ No newline at end of file diff --git a/graphql/documents/queries/misc.graphql b/graphql/documents/queries/misc.graphql index 2786ca997..fe9e85da7 100644 --- a/graphql/documents/queries/misc.graphql +++ b/graphql/documents/queries/misc.graphql @@ -1,9 +1,3 @@ -query FindTag($id: ID!) { - findTag(id: $id) { - ...TagData - } -} - query MarkerStrings($q: String, $sort: String) { markerStrings(q: $q, sort: $sort) { id diff --git a/graphql/documents/queries/tag.graphql b/graphql/documents/queries/tag.graphql new file mode 100644 index 000000000..468b70d7d --- /dev/null +++ b/graphql/documents/queries/tag.graphql @@ -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 + } +} \ No newline at end of file diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 9624a41a6..a36f5062d 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -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!]! diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 2ba65b876..2499f4cfe 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -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, diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index d1cf5bb5a..68fb53e81 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -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!]! } \ No newline at end of file diff --git a/pkg/api/context_keys.go b/pkg/api/context_keys.go index 917e1c587..5b0581bef 100644 --- a/pkg/api/context_keys.go +++ b/pkg/api/context_keys.go @@ -11,4 +11,5 @@ const ( studioKey key = 3 movieKey key = 4 ContextUser key = 5 + tagKey key = 6 ) diff --git a/pkg/api/resolver_model_tag.go b/pkg/api/resolver_model_tag.go index 26a13dfc7..24e44fdb2 100644 --- a/pkg/api/resolver_model_tag.go +++ b/pkg/api/resolver_model_tag.go @@ -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 +} diff --git a/pkg/api/resolver_mutation_tag.go b/pkg/api/resolver_mutation_tag.go index 192b06fde..e62db0b65 100644 --- a/pkg/api/resolver_mutation_tag.go +++ b/pkg/api/resolver_mutation_tag.go @@ -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 diff --git a/pkg/api/resolver_query_find_tag.go b/pkg/api/resolver_query_find_tag.go index 64fc866c9..7e8727b9b 100644 --- a/pkg/api/resolver_query_find_tag.go +++ b/pkg/api/resolver_query_find_tag.go @@ -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() diff --git a/pkg/api/routes_studio.go b/pkg/api/routes_studio.go index daaaa7822..4dbc023ba 100644 --- a/pkg/api/routes_studio.go +++ b/pkg/api/routes_studio.go @@ -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 { diff --git a/pkg/api/routes_tag.go b/pkg/api/routes_tag.go new file mode 100644 index 000000000..d932d720e --- /dev/null +++ b/pkg/api/routes_tag.go @@ -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)) + }) +} diff --git a/pkg/api/server.go b/pkg/api/server.go index dccf25144..8b118fe05 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -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") diff --git a/pkg/api/urlbuilders/tag.go b/pkg/api/urlbuilders/tag.go new file mode 100644 index 000000000..e3f9415c9 --- /dev/null +++ b/pkg/api/urlbuilders/tag.go @@ -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" +} diff --git a/pkg/database/database.go b/pkg/database/database.go index 3dcedba64..898703918 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -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" diff --git a/pkg/database/migrations/11_tag_image.up.sql b/pkg/database/migrations/11_tag_image.up.sql new file mode 100644 index 000000000..e40c969a9 --- /dev/null +++ b/pkg/database/migrations/11_tag_image.up.sql @@ -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`); diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 72fae7616..a01eb1629 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -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{ diff --git a/pkg/manager/json_utils.go b/pkg/manager/json_utils.go index 384f5937c..1d5dbc4a0 100644 --- a/pkg/manager/json_utils.go +++ b/pkg/manager/json_utils.go @@ -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)) } diff --git a/pkg/manager/jsonschema/mappings.go b/pkg/manager/jsonschema/mappings.go index 621ffc024..2e65f1023 100644 --- a/pkg/manager/jsonschema/mappings.go +++ b/pkg/manager/jsonschema/mappings.go @@ -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"` diff --git a/pkg/manager/jsonschema/tag.go b/pkg/manager/jsonschema/tag.go new file mode 100644 index 000000000..06ca48be8 --- /dev/null +++ b/pkg/manager/jsonschema/tag.go @@ -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) +} diff --git a/pkg/manager/paths/paths_json.go b/pkg/manager/paths/paths_json.go index 3f2ccbcb1..448b3734c 100644 --- a/pkg/manager/paths/paths_json.go +++ b/pkg/manager/paths/paths_json.go @@ -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") } diff --git a/pkg/manager/tag.go b/pkg/manager/tag.go new file mode 100644 index 000000000..ac7809b3e --- /dev/null +++ b/pkg/manager/tag.go @@ -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 +} diff --git a/pkg/manager/task_export.go b/pkg/manager/task_export.go index fde843280..3ef63415d 100644 --- a/pkg/manager/task_export.go +++ b/pkg/manager/task_export.go @@ -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 diff --git a/pkg/manager/task_import.go b/pkg/manager/task_import.go index d205ea417..33b5f2e82 100644 --- a/pkg/manager/task_import.go +++ b/pkg/manager/task_import.go @@ -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") diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index ee3c79d32..d62d83784 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -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(` + + + + + + image/svg+xml + + + + + + + + +`) + +// var DefaultTagImage = []byte(` +// +// +// +// +// +// image/svg+xml +// +// +// +// +// +// +// +// +// `) diff --git a/pkg/models/querybuilder_scene_test.go b/pkg/models/querybuilder_scene_test.go index 7a0e33918..d15aa9090 100644 --- a/pkg/models/querybuilder_scene_test.go +++ b/pkg/models/querybuilder_scene_test.go @@ -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") diff --git a/pkg/models/querybuilder_sql.go b/pkg/models/querybuilder_sql.go index 64ad9fced..79c068954 100644 --- a/pkg/models/querybuilder_sql.go +++ b/pkg/models/querybuilder_sql.go @@ -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 } diff --git a/pkg/models/querybuilder_tag.go b/pkg/models/querybuilder_tag.go index 35c64c323..46c44e6df 100644 --- a/pkg/models/querybuilder_tag.go +++ b/pkg/models/querybuilder_tag.go @@ -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) +} diff --git a/pkg/models/querybuilder_tag_test.go b/pkg/models/querybuilder_tag_test.go index 052c89fc3..021d2aa1e 100644 --- a/pkg/models/querybuilder_tag_test.go +++ b/pkg/models/querybuilder_tag_test.go @@ -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 diff --git a/pkg/models/setup_test.go b/pkg/models/setup_test.go index 35a345c99..ff54d9b47 100644 --- a/pkg/models/setup_test.go +++ b/pkg/models/setup_test.go @@ -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) +} diff --git a/pkg/utils/image.go b/pkg/utils/image.go index 978fbbf42..7c550a0be 100644 --- a/pkg/utils/image.go +++ b/pkg/utils/image.go @@ -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 diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 32b3e73c1..92299389b 100755 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -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 = () => { - + diff --git a/ui/v2.5/src/components/Changelog/versions/v030.tsx b/ui/v2.5/src/components/Changelog/versions/v030.tsx index 0b7c87b07..41ae84f35 100644 --- a/ui/v2.5/src/components/Changelog/versions/v030.tsx +++ b/ui/v2.5/src/components/Changelog/versions/v030.tsx @@ -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. diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index 0381e0435..ae74ba724 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -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 ? ( diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 6fb8ef275..defe959cc 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -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 = ({ + subComponent, + filterHook, +}) => { const history = useHistory(); const otherOperations = [ { @@ -34,6 +42,8 @@ export const SceneMarkerList: React.FC = () => { const listData = useSceneMarkersList({ otherOperations, renderContent, + subComponent, + filterHook, addKeybinds, }); diff --git a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx index daede0ded..249387202 100644 --- a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx @@ -447,7 +447,7 @@ export const SettingsConfigurationPanel: React.FC = () => { } value={logLevel} > - {["Debug", "Info", "Warning", "Error"].map((o) => ( + {["Trace", "Debug", "Info", "Warning", "Error"].map((o) => ( diff --git a/ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx index d7401b514..ac91e3d40 100644 --- a/ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx @@ -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); diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx new file mode 100644 index 000000000..3da8f4b68 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -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 = ({ tag, zoomIndex }) => { + function maybeRenderScenesPopoverButton() { + if (!tag.scene_count) return; + + return ( + + + + ); + } + + function maybeRenderSceneMarkersPopoverButton() { + if (!tag.scene_marker_count) return; + + return ( + + + + ); + } + + function maybeRenderPopoverButtonGroup() { + if (tag) { + return ( + <> +
+ + {maybeRenderScenesPopoverButton()} + {maybeRenderSceneMarkersPopoverButton()} + + + ); + } + } + + return ( + + + {tag.name} + +
+
{tag.name}
+
+ {maybeRenderPopoverButtonGroup()} +
+ ); +}; diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx new file mode 100644 index 000000000..c48eda6fe --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -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(isNew); + const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + + // Editing tag state + const [image, setImage] = useState(); + const [name, setName] = useState(); + + // Tag state + const [tag, setTag] = useState>({}); + const [imagePreview, setImagePreview] = useState(); + + 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) { + setName(state.name); + } + + function updateTagData(tagData: Partial) { + 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 ; + if (error) return
{error.message}
; + } + + function getTagInput() { + const input: Partial = { + 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) { + ImageUtils.onImageChange(event, onImageLoad); + } + + function renderDeleteAlert() { + return ( + setIsDeleteAlertOpen(false) }} + > +

Are you sure you want to delete {name ?? "tag"}?

+
+ ); + } + + function onToggleEdit() { + setIsEditing(!isEditing); + updateTagData(tag); + } + + return ( +
+
+ {isNew &&

Add Tag

} +
+ {imageEncoding ? ( + + ) : ( + {name} + )} +
+ + + {TableUtils.renderInputGroup({ + title: "Name", + value: name ?? "", + isEditing: !!isEditing, + onChange: setName, + })} + +
+ +
+ {!isNew && ( +
+ + + + + + + + +
+ )} + {renderDeleteAlert()} +
+ ); +}; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx new file mode 100644 index 000000000..38380eb86 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -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; +} + +export const TagMarkersPanel: React.FC = ({ 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 ; +}; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx new file mode 100644 index 000000000..9654750f7 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx @@ -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; +} + +export const TagScenesPanel: React.FC = ({ 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 ; +}; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index e7d3fa218..a65b397ca 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -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 = ({ filterHook }) => { const Toast = useToast(); - // Editing / New state - const [name, setName] = useState(""); - const [editingTag, setEditingTag] = useState | null>(null); const [deletingTag, setDeletingTag] = useState | 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 = { name }; - if (editingTag) - (tagInput as Partial).id = editingTag.id; - return tagInput; - } + const listData = useTagsList({ + renderContent, + filterHook, + zoomable: true, + defaultZoomIndex: 0, + }); function getDeleteTagInput() { const tagInput: Partial = {}; @@ -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 = () => { ); - if (!data?.allTags) return ; - if (error) return
{error.message}
; + function renderContent( + result: FindTagsQueryResult, + filter: ListFilterModel, + selectedIds: Set, + zoomIndex: number + ) { + if (!result.data?.findTags) return; - const tagElements = data.allTags.map((tag) => { - return ( -
- -
- - - - - Total:{" "} - - - + if (filter.displayMode === DisplayMode.Grid) { + return ( +
+ {result.data.findTags.tags.map((tag) => ( + + ))}
-
- ); - }); + ); + } + if (filter.displayMode === DisplayMode.List) { + const tagElements = result.data.findTags.tags.map((tag) => { + return ( +
+ {tag.name} - return ( -
- +
+ + + + + Total:{" "} + + + +
+
+ ); + }); - setEditingTag(null)} - accept={{ - onClick: onEdit, - variant: "danger", - text: editingTag?.id ? "Update" : "Create", - }} - > - - Name - ) => - setName(newValue.currentTarget.value) - } - defaultValue={(editingTag && editingTag.name) || ""} - /> - - + return ( +
+ {tagElements} + {deleteAlert} +
+ ); + } + if (filter.displayMode === DisplayMode.Wall) { + return

TODO

; + } + } - {tagElements} - {deleteAlert} -
- ); + return listData.template; }; diff --git a/ui/v2.5/src/components/Tags/Tags.tsx b/ui/v2.5/src/components/Tags/Tags.tsx new file mode 100644 index 000000000..4865972f1 --- /dev/null +++ b/ui/v2.5/src/components/Tags/Tags.tsx @@ -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 = () => ( + + + + +); + +export default Tags; diff --git a/ui/v2.5/src/components/Tags/styles.scss b/ui/v2.5/src/components/Tags/styles.scss index 78bacf7d0..f7371dd27 100644 --- a/ui/v2.5/src/components/Tags/styles.scss +++ b/ui/v2.5/src/components/Tags/styles.scss @@ -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%; + } +} diff --git a/ui/v2.5/src/components/Wall/styles.scss b/ui/v2.5/src/components/Wall/styles.scss index c88b5558a..da38cb5f0 100644 --- a/ui/v2.5/src/components/Wall/styles.scss +++ b/ui/v2.5/src/components/Wall/styles.scss @@ -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 { diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index adee07ec7..ca3f6bfb1 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -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({ 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 = [ diff --git a/ui/v2.5/src/docs/en/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/KeyboardShortcuts.md index 6ab9145dd..0a5b65fa7 100644 --- a/ui/v2.5/src/docs/en/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/KeyboardShortcuts.md @@ -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 | \ No newline at end of file diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx index 87e7d6e4b..1d615f54c 100644 --- a/ui/v2.5/src/hooks/ListHook.tsx +++ b/ui/v2.5/src/hooks/ListHook.tsx @@ -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 { subComponent?: boolean; filterHook?: (filter: ListFilterModel) => ListFilterModel; zoomable?: boolean; + defaultZoomIndex?: number; otherOperations?: IListHookOperation[]; renderContent: ( result: T, @@ -111,7 +115,9 @@ const useList = ( const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastClickedId, setLastClickedId] = useState(); - const [zoomIndex, setZoomIndex] = useState(1); + const [zoomIndex, setZoomIndex] = useState( + options.defaultZoomIndex ?? 1 + ); const result = options.useData(getFilter()); const totalCount = options.getCount(result); @@ -573,3 +579,18 @@ export const useMoviesList = ( selectedIds: Set ) => getSelectedData(result?.data?.findMovies?.movies ?? [], selectedIds), }); + +export const useTagsList = ( + props: IListHookOptions +) => + useList({ + ...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) => + getSelectedData(result?.data?.findTags?.tags ?? [], selectedIds), + }); diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 095b7e277..66dc5b5d4 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -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%; } diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index a10433844..02164b8b5 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -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"; } } diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index 7e537d0fd..c877bde1e 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -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"; +} diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 1ccf12ea6..8b1cf9725 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -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": diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 9320e6644..0458e5bca 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -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; + } } diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index a9a35bea9..11e66596f 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -13,6 +13,7 @@ export enum FilterMode { Galleries, SceneMarkers, Movies, + Tags, } export interface ILabeledId {