mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
Minor refactor (#3924)
* Move SceneFilenameParser to scene package * Move Timestamp marshalling to internal/api, use gqlgen Int64 parser
This commit is contained in:
parent
8872892c42
commit
ec14ad7564
7 changed files with 64 additions and 108 deletions
12
gqlgen.yml
12
gqlgen.yml
|
|
@ -23,10 +23,10 @@ autobind:
|
||||||
|
|
||||||
models:
|
models:
|
||||||
# Scalars
|
# Scalars
|
||||||
Timestamp:
|
|
||||||
model: github.com/stashapp/stash/pkg/models.Timestamp
|
|
||||||
Int64:
|
Int64:
|
||||||
model: github.com/stashapp/stash/pkg/models.Int64
|
model: github.com/99designs/gqlgen/graphql.Int64
|
||||||
|
Timestamp:
|
||||||
|
model: github.com/stashapp/stash/internal/api.Timestamp
|
||||||
# define to force resolvers
|
# define to force resolvers
|
||||||
Image:
|
Image:
|
||||||
model: github.com/stashapp/stash/pkg/models.Image
|
model: github.com/stashapp/stash/pkg/models.Image
|
||||||
|
|
@ -54,12 +54,6 @@ models:
|
||||||
model: github.com/stashapp/stash/internal/manager/config.ScanMetadataOptions
|
model: github.com/stashapp/stash/internal/manager/config.ScanMetadataOptions
|
||||||
AutoTagMetadataOptions:
|
AutoTagMetadataOptions:
|
||||||
model: github.com/stashapp/stash/internal/manager/config.AutoTagMetadataOptions
|
model: github.com/stashapp/stash/internal/manager/config.AutoTagMetadataOptions
|
||||||
SceneParserInput:
|
|
||||||
model: github.com/stashapp/stash/internal/manager.SceneParserInput
|
|
||||||
SceneParserResult:
|
|
||||||
model: github.com/stashapp/stash/internal/manager.SceneParserResult
|
|
||||||
SceneMovieID:
|
|
||||||
model: github.com/stashapp/stash/internal/manager.SceneMovieID
|
|
||||||
SystemStatus:
|
SystemStatus:
|
||||||
model: github.com/stashapp/stash/internal/manager.SystemStatus
|
model: github.com/stashapp/stash/internal/manager.SystemStatus
|
||||||
SystemStatusEnum:
|
SystemStatusEnum:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package models
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package models
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -5,8 +5,9 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/99designs/gqlgen/graphql"
|
"github.com/99designs/gqlgen/graphql"
|
||||||
"github.com/stashapp/stash/internal/manager"
|
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stashapp/stash/pkg/scene"
|
||||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -189,11 +190,11 @@ func (r *queryResolver) FindScenesByPathRegex(ctx context.Context, filter *model
|
||||||
return ret, nil
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.FindFilterType, config manager.SceneParserInput) (ret *SceneParserResultType, err error) {
|
func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.FindFilterType, config models.SceneParserInput) (ret *SceneParserResultType, err error) {
|
||||||
parser := manager.NewSceneFilenameParser(filter, config)
|
parser := scene.NewFilenameParser(filter, config)
|
||||||
|
|
||||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||||
result, count, err := parser.Parse(ctx, manager.SceneFilenameParserRepository{
|
result, count, err := parser.Parse(ctx, scene.FilenameParserRepository{
|
||||||
Scene: r.repository.Scene,
|
Scene: r.repository.Scene,
|
||||||
Performer: r.repository.Performer,
|
Performer: r.repository.Performer,
|
||||||
Studio: r.repository.Studio,
|
Studio: r.repository.Studio,
|
||||||
|
|
|
||||||
30
pkg/models/filename_parser.go
Normal file
30
pkg/models/filename_parser.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type SceneParserInput struct {
|
||||||
|
IgnoreWords []string `json:"ignoreWords"`
|
||||||
|
WhitespaceCharacters *string `json:"whitespaceCharacters"`
|
||||||
|
CapitalizeTitle *bool `json:"capitalizeTitle"`
|
||||||
|
IgnoreOrganized *bool `json:"ignoreOrganized"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneParserResult struct {
|
||||||
|
Scene *Scene `json:"scene"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Code *string `json:"code"`
|
||||||
|
Details *string `json:"details"`
|
||||||
|
Director *string `json:"director"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
Date *string `json:"date"`
|
||||||
|
Rating *int `json:"rating"`
|
||||||
|
Rating100 *int `json:"rating100"`
|
||||||
|
StudioID *string `json:"studio_id"`
|
||||||
|
GalleryIds []string `json:"gallery_ids"`
|
||||||
|
PerformerIds []string `json:"performer_ids"`
|
||||||
|
Movies []*SceneMovieID `json:"movies"`
|
||||||
|
TagIds []string `json:"tag_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneMovieID struct {
|
||||||
|
MovieID string `json:"movie_id"`
|
||||||
|
SceneIndex *string `json:"scene_index"`
|
||||||
|
}
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/99designs/gqlgen/graphql"
|
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrInt64 = errors.New("cannot parse Int64")
|
|
||||||
|
|
||||||
func MarshalInt64(v int64) graphql.Marshaler {
|
|
||||||
return graphql.WriterFunc(func(w io.Writer) {
|
|
||||||
_, err := io.WriteString(w, strconv.FormatInt(v, 10))
|
|
||||||
if err != nil {
|
|
||||||
logger.Warnf("could not marshal int64: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func UnmarshalInt64(v interface{}) (int64, error) {
|
|
||||||
if tmpStr, ok := v.(string); ok {
|
|
||||||
if len(tmpStr) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ret, err := strconv.ParseInt(tmpStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("cannot parse %v as Int64: %w", tmpStr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("%w: not a string", ErrInt64)
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package manager
|
package scene
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -9,42 +9,12 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/scene"
|
|
||||||
"github.com/stashapp/stash/pkg/studio"
|
"github.com/stashapp/stash/pkg/studio"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
"github.com/stashapp/stash/pkg/tag"
|
"github.com/stashapp/stash/pkg/tag"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SceneParserInput struct {
|
|
||||||
IgnoreWords []string `json:"ignoreWords"`
|
|
||||||
WhitespaceCharacters *string `json:"whitespaceCharacters"`
|
|
||||||
CapitalizeTitle *bool `json:"capitalizeTitle"`
|
|
||||||
IgnoreOrganized *bool `json:"ignoreOrganized"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SceneParserResult struct {
|
|
||||||
Scene *models.Scene `json:"scene"`
|
|
||||||
Title *string `json:"title"`
|
|
||||||
Code *string `json:"code"`
|
|
||||||
Details *string `json:"details"`
|
|
||||||
Director *string `json:"director"`
|
|
||||||
URL *string `json:"url"`
|
|
||||||
Date *string `json:"date"`
|
|
||||||
Rating *int `json:"rating"`
|
|
||||||
Rating100 *int `json:"rating100"`
|
|
||||||
StudioID *string `json:"studio_id"`
|
|
||||||
GalleryIds []string `json:"gallery_ids"`
|
|
||||||
PerformerIds []string `json:"performer_ids"`
|
|
||||||
Movies []*SceneMovieID `json:"movies"`
|
|
||||||
TagIds []string `json:"tag_ids"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SceneMovieID struct {
|
|
||||||
MovieID string `json:"movie_id"`
|
|
||||||
SceneIndex *string `json:"scene_index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type parserField struct {
|
type parserField struct {
|
||||||
field string
|
field string
|
||||||
fieldRegex *regexp.Regexp
|
fieldRegex *regexp.Regexp
|
||||||
|
|
@ -435,9 +405,9 @@ func (m parseMapper) parse(scene *models.Scene) *sceneHolder {
|
||||||
return sceneHolder
|
return sceneHolder
|
||||||
}
|
}
|
||||||
|
|
||||||
type SceneFilenameParser struct {
|
type FilenameParser struct {
|
||||||
Pattern string
|
Pattern string
|
||||||
ParserInput SceneParserInput
|
ParserInput models.SceneParserInput
|
||||||
Filter *models.FindFilterType
|
Filter *models.FindFilterType
|
||||||
whitespaceRE *regexp.Regexp
|
whitespaceRE *regexp.Regexp
|
||||||
performerCache map[string]*models.Performer
|
performerCache map[string]*models.Performer
|
||||||
|
|
@ -446,8 +416,8 @@ type SceneFilenameParser struct {
|
||||||
tagCache map[string]*models.Tag
|
tagCache map[string]*models.Tag
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSceneFilenameParser(filter *models.FindFilterType, config SceneParserInput) *SceneFilenameParser {
|
func NewFilenameParser(filter *models.FindFilterType, config models.SceneParserInput) *FilenameParser {
|
||||||
p := &SceneFilenameParser{
|
p := &FilenameParser{
|
||||||
Pattern: *filter.Q,
|
Pattern: *filter.Q,
|
||||||
ParserInput: config,
|
ParserInput: config,
|
||||||
Filter: filter,
|
Filter: filter,
|
||||||
|
|
@ -463,7 +433,7 @@ func NewSceneFilenameParser(filter *models.FindFilterType, config SceneParserInp
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) initWhiteSpaceRegex() {
|
func (p *FilenameParser) initWhiteSpaceRegex() {
|
||||||
compileREs()
|
compileREs()
|
||||||
|
|
||||||
wsChars := ""
|
wsChars := ""
|
||||||
|
|
@ -479,15 +449,15 @@ func (p *SceneFilenameParser) initWhiteSpaceRegex() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type SceneFilenameParserRepository struct {
|
type FilenameParserRepository struct {
|
||||||
Scene scene.Queryer
|
Scene Queryer
|
||||||
Performer PerformerNamesFinder
|
Performer PerformerNamesFinder
|
||||||
Studio studio.Queryer
|
Studio studio.Queryer
|
||||||
Movie MovieNameFinder
|
Movie MovieNameFinder
|
||||||
Tag tag.Queryer
|
Tag tag.Queryer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) Parse(ctx context.Context, repo SceneFilenameParserRepository) ([]*SceneParserResult, int, error) {
|
func (p *FilenameParser) Parse(ctx context.Context, repo FilenameParserRepository) ([]*models.SceneParserResult, int, error) {
|
||||||
// perform the query to find the scenes
|
// perform the query to find the scenes
|
||||||
mapper, err := newParseMapper(p.Pattern, p.ParserInput.IgnoreWords)
|
mapper, err := newParseMapper(p.Pattern, p.ParserInput.IgnoreWords)
|
||||||
|
|
||||||
|
|
@ -509,7 +479,7 @@ func (p *SceneFilenameParser) Parse(ctx context.Context, repo SceneFilenameParse
|
||||||
|
|
||||||
p.Filter.Q = nil
|
p.Filter.Q = nil
|
||||||
|
|
||||||
scenes, total, err := scene.QueryWithCount(ctx, repo.Scene, sceneFilter, p.Filter)
|
scenes, total, err := QueryWithCount(ctx, repo.Scene, sceneFilter, p.Filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -519,13 +489,13 @@ func (p *SceneFilenameParser) Parse(ctx context.Context, repo SceneFilenameParse
|
||||||
return ret, total, nil
|
return ret, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) parseScenes(ctx context.Context, repo SceneFilenameParserRepository, scenes []*models.Scene, mapper *parseMapper) []*SceneParserResult {
|
func (p *FilenameParser) parseScenes(ctx context.Context, repo FilenameParserRepository, scenes []*models.Scene, mapper *parseMapper) []*models.SceneParserResult {
|
||||||
var ret []*SceneParserResult
|
var ret []*models.SceneParserResult
|
||||||
for _, scene := range scenes {
|
for _, scene := range scenes {
|
||||||
sceneHolder := mapper.parse(scene)
|
sceneHolder := mapper.parse(scene)
|
||||||
|
|
||||||
if sceneHolder != nil {
|
if sceneHolder != nil {
|
||||||
r := &SceneParserResult{
|
r := &models.SceneParserResult{
|
||||||
Scene: scene,
|
Scene: scene,
|
||||||
}
|
}
|
||||||
p.setParserResult(ctx, repo, *sceneHolder, r)
|
p.setParserResult(ctx, repo, *sceneHolder, r)
|
||||||
|
|
@ -537,7 +507,7 @@ func (p *SceneFilenameParser) parseScenes(ctx context.Context, repo SceneFilenam
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p SceneFilenameParser) replaceWhitespaceCharacters(value string) string {
|
func (p FilenameParser) replaceWhitespaceCharacters(value string) string {
|
||||||
if p.whitespaceRE != nil {
|
if p.whitespaceRE != nil {
|
||||||
value = p.whitespaceRE.ReplaceAllString(value, " ")
|
value = p.whitespaceRE.ReplaceAllString(value, " ")
|
||||||
// remove consecutive spaces
|
// remove consecutive spaces
|
||||||
|
|
@ -551,7 +521,7 @@ type PerformerNamesFinder interface {
|
||||||
FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error)
|
FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) queryPerformer(ctx context.Context, qb PerformerNamesFinder, performerName string) *models.Performer {
|
func (p *FilenameParser) queryPerformer(ctx context.Context, qb PerformerNamesFinder, performerName string) *models.Performer {
|
||||||
// massage the performer name
|
// massage the performer name
|
||||||
performerName = delimiterRE.ReplaceAllString(performerName, " ")
|
performerName = delimiterRE.ReplaceAllString(performerName, " ")
|
||||||
|
|
||||||
|
|
@ -574,7 +544,7 @@ func (p *SceneFilenameParser) queryPerformer(ctx context.Context, qb PerformerNa
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) queryStudio(ctx context.Context, qb studio.Queryer, studioName string) *models.Studio {
|
func (p *FilenameParser) queryStudio(ctx context.Context, qb studio.Queryer, studioName string) *models.Studio {
|
||||||
// massage the performer name
|
// massage the performer name
|
||||||
studioName = delimiterRE.ReplaceAllString(studioName, " ")
|
studioName = delimiterRE.ReplaceAllString(studioName, " ")
|
||||||
|
|
||||||
|
|
@ -600,7 +570,7 @@ type MovieNameFinder interface {
|
||||||
FindByName(ctx context.Context, name string, nocase bool) (*models.Movie, error)
|
FindByName(ctx context.Context, name string, nocase bool) (*models.Movie, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) queryMovie(ctx context.Context, qb MovieNameFinder, movieName string) *models.Movie {
|
func (p *FilenameParser) queryMovie(ctx context.Context, qb MovieNameFinder, movieName string) *models.Movie {
|
||||||
// massage the movie name
|
// massage the movie name
|
||||||
movieName = delimiterRE.ReplaceAllString(movieName, " ")
|
movieName = delimiterRE.ReplaceAllString(movieName, " ")
|
||||||
|
|
||||||
|
|
@ -617,7 +587,7 @@ func (p *SceneFilenameParser) queryMovie(ctx context.Context, qb MovieNameFinder
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) queryTag(ctx context.Context, qb tag.Queryer, tagName string) *models.Tag {
|
func (p *FilenameParser) queryTag(ctx context.Context, qb tag.Queryer, tagName string) *models.Tag {
|
||||||
// massage the tag name
|
// massage the tag name
|
||||||
tagName = delimiterRE.ReplaceAllString(tagName, " ")
|
tagName = delimiterRE.ReplaceAllString(tagName, " ")
|
||||||
|
|
||||||
|
|
@ -640,7 +610,7 @@ func (p *SceneFilenameParser) queryTag(ctx context.Context, qb tag.Queryer, tagN
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFinder, h sceneHolder, result *SceneParserResult) {
|
func (p *FilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFinder, h sceneHolder, result *models.SceneParserResult) {
|
||||||
// query for each performer
|
// query for each performer
|
||||||
performersSet := make(map[int]bool)
|
performersSet := make(map[int]bool)
|
||||||
for _, performerName := range h.performers {
|
for _, performerName := range h.performers {
|
||||||
|
|
@ -656,7 +626,7 @@ func (p *SceneFilenameParser) setPerformers(ctx context.Context, qb PerformerNam
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) setTags(ctx context.Context, qb tag.Queryer, h sceneHolder, result *SceneParserResult) {
|
func (p *FilenameParser) setTags(ctx context.Context, qb tag.Queryer, h sceneHolder, result *models.SceneParserResult) {
|
||||||
// query for each performer
|
// query for each performer
|
||||||
tagsSet := make(map[int]bool)
|
tagsSet := make(map[int]bool)
|
||||||
for _, tagName := range h.tags {
|
for _, tagName := range h.tags {
|
||||||
|
|
@ -672,7 +642,7 @@ func (p *SceneFilenameParser) setTags(ctx context.Context, qb tag.Queryer, h sce
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) setStudio(ctx context.Context, qb studio.Queryer, h sceneHolder, result *SceneParserResult) {
|
func (p *FilenameParser) setStudio(ctx context.Context, qb studio.Queryer, h sceneHolder, result *models.SceneParserResult) {
|
||||||
// query for each performer
|
// query for each performer
|
||||||
if h.studio != "" {
|
if h.studio != "" {
|
||||||
studio := p.queryStudio(ctx, qb, h.studio)
|
studio := p.queryStudio(ctx, qb, h.studio)
|
||||||
|
|
@ -683,7 +653,7 @@ func (p *SceneFilenameParser) setStudio(ctx context.Context, qb studio.Queryer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) setMovies(ctx context.Context, qb MovieNameFinder, h sceneHolder, result *SceneParserResult) {
|
func (p *FilenameParser) setMovies(ctx context.Context, qb MovieNameFinder, h sceneHolder, result *models.SceneParserResult) {
|
||||||
// query for each movie
|
// query for each movie
|
||||||
moviesSet := make(map[int]bool)
|
moviesSet := make(map[int]bool)
|
||||||
for _, movieName := range h.movies {
|
for _, movieName := range h.movies {
|
||||||
|
|
@ -691,7 +661,7 @@ func (p *SceneFilenameParser) setMovies(ctx context.Context, qb MovieNameFinder,
|
||||||
movie := p.queryMovie(ctx, qb, movieName)
|
movie := p.queryMovie(ctx, qb, movieName)
|
||||||
if movie != nil {
|
if movie != nil {
|
||||||
if _, found := moviesSet[movie.ID]; !found {
|
if _, found := moviesSet[movie.ID]; !found {
|
||||||
result.Movies = append(result.Movies, &SceneMovieID{
|
result.Movies = append(result.Movies, &models.SceneMovieID{
|
||||||
MovieID: strconv.Itoa(movie.ID),
|
MovieID: strconv.Itoa(movie.ID),
|
||||||
})
|
})
|
||||||
moviesSet[movie.ID] = true
|
moviesSet[movie.ID] = true
|
||||||
|
|
@ -701,7 +671,7 @@ func (p *SceneFilenameParser) setMovies(ctx context.Context, qb MovieNameFinder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SceneFilenameParser) setParserResult(ctx context.Context, repo SceneFilenameParserRepository, h sceneHolder, result *SceneParserResult) {
|
func (p *FilenameParser) setParserResult(ctx context.Context, repo FilenameParserRepository, h sceneHolder, result *models.SceneParserResult) {
|
||||||
if h.result.Title != "" {
|
if h.result.Title != "" {
|
||||||
title := h.result.Title
|
title := h.result.Title
|
||||||
title = p.replaceWhitespaceCharacters(title)
|
title = p.replaceWhitespaceCharacters(title)
|
||||||
Loading…
Reference in a new issue