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:
WithoutPants
2020-07-07 10:35:43 +10:00
committed by GitHub
parent 54430dbc11
commit 244ae54f3f
55 changed files with 1526 additions and 228 deletions

View File

@@ -1,6 +1,7 @@
fragment TagData on Tag {
id
name
image_path
scene_count
scene_marker_count
}

View File

@@ -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
}
}

View File

@@ -1,9 +1,3 @@
query FindTag($id: ID!) {
findTag(id: $id) {
...TagData
}
}
query MarkerStrings($q: String, $sort: String) {
markerStrings(q: $q, sort: $sort) {
id

View 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
}
}

View File

@@ -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!]!

View File

@@ -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,

View File

@@ -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!]!
}

View File

@@ -11,4 +11,5 @@ const (
studioKey key = 3
movieKey key = 4
ContextUser key = 5
tagKey key = 6
)

View File

@@ -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
}

View File

@@ -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,14 +21,40 @@ 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()
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 {
@@ -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

View File

@@ -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()

View File

@@ -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
View 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))
})
}

View File

@@ -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")

View 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"
}

View File

@@ -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"

View 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`);

View File

@@ -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{

View File

@@ -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))
}

View File

@@ -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"`

View 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)
}

View File

@@ -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
View 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
}

View File

@@ -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

View File

@@ -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)
// Process the base 64 encoded image string
var imageData []byte
if len(tagJSON.Image) > 0 {
_, imageData, err = utils.ProcessBase64Image(tagJSON.Image)
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...)
}
_ = 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")

View File

@@ -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>`)

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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} />

View File

@@ -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.

View File

@@ -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 ? (

View File

@@ -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,
});

View File

@@ -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>

View File

@@ -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);

View 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>
);
};

View 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>
);
};

View 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} />;
};

View 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} />;
};

View File

@@ -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,15 +74,29 @@ 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) => {
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>
);
}
if (filter.displayMode === DisplayMode.List) {
const tagElements = result.data.findTags.tags.map((tag) => {
return (
<div key={tag.id} className="tag-list-row row">
<Button variant="link" onClick={() => setEditingTag(tag)}>
{tag.name}
</Button>
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
<div className="ml-auto">
<Button
variant="secondary"
@@ -124,7 +118,8 @@ export const TagList: React.FC = () => {
to={NavUtils.makeTagSceneMarkersUrl(tag)}
className="tag-list-anchor"
>
Markers: <FormattedNumber value={tag.scene_marker_count ?? 0} />
Markers:{" "}
<FormattedNumber value={tag.scene_marker_count ?? 0} />
</Link>
</Button>
<span className="tag-list-count">
@@ -143,37 +138,15 @@ export const TagList: React.FC = () => {
return (
<div className="col col-sm-8 m-auto">
<Button
variant="primary"
className="mt-2"
onClick={() => setEditingTag({})}
>
New Tag
</Button>
<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>
{tagElements}
{deleteAlert}
</div>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
}
return listData.template;
};

View 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;

View File

@@ -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%;
}
}

View File

@@ -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 {

View File

@@ -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 = [

View File

@@ -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 |

View File

@@ -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),
});

View File

@@ -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%;
}

View File

@@ -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";
}
}

View File

@@ -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";
}

View File

@@ -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":

View File

@@ -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;
}
}

View File

@@ -13,6 +13,7 @@ export enum FilterMode {
Galleries,
SceneMarkers,
Movies,
Tags,
}
export interface ILabeledId {