mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 07:41:08 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03cbaf9111 |
@@ -47,10 +47,6 @@ func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInp
|
||||
Database: mgr.Database,
|
||||
}
|
||||
|
||||
if err := t.PreExecute(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jobID := mgr.JobManager.Add(ctx, "Migrating database...", t)
|
||||
|
||||
return strconv.Itoa(jobID), nil
|
||||
|
||||
@@ -33,26 +33,15 @@ func (r *queryResolver) FindJob(ctx context.Context, input FindJobInput) (*Job,
|
||||
}
|
||||
|
||||
func jobToJobModel(j job.Job) *Job {
|
||||
subTasks := make([]string, len(j.Details))
|
||||
for i, t := range j.Details {
|
||||
subTasks[i] = sanitiseWebsocketString(t)
|
||||
}
|
||||
|
||||
var jobError *string
|
||||
if j.Error != nil {
|
||||
s := sanitiseWebsocketString(*j.Error)
|
||||
jobError = &s
|
||||
}
|
||||
|
||||
ret := &Job{
|
||||
ID: strconv.Itoa(j.ID),
|
||||
Status: JobStatus(j.Status),
|
||||
Description: sanitiseWebsocketString(j.Description),
|
||||
SubTasks: subTasks,
|
||||
Description: j.Description,
|
||||
SubTasks: j.Details,
|
||||
StartTime: j.StartTime,
|
||||
EndTime: j.EndTime,
|
||||
AddTime: j.AddTime,
|
||||
Error: jobError,
|
||||
Error: j.Error,
|
||||
}
|
||||
|
||||
if j.Progress != -1 {
|
||||
|
||||
@@ -2,19 +2,11 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/internal/log"
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
)
|
||||
|
||||
// sanitiseWebsocketString is used to ensure that any strings sent over the websocket are valid UTF-8.
|
||||
// Any invalid UTF-8 sequences will be replaced with the Unicode replacement character (U+FFFD).
|
||||
// Invalid UTF-8 sequences can cause the websocket connection to be closed.
|
||||
func sanitiseWebsocketString(s string) string {
|
||||
return strings.ToValidUTF8(s, "\uFFFD")
|
||||
}
|
||||
|
||||
func getLogLevel(logType string) LogLevel {
|
||||
switch logType {
|
||||
case "progress":
|
||||
@@ -41,7 +33,7 @@ func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry {
|
||||
ret[i] = &LogEntry{
|
||||
Time: entry.Time,
|
||||
Level: getLogLevel(entry.Type),
|
||||
Message: sanitiseWebsocketString(entry.Message),
|
||||
Message: entry.Message,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/job"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/sqlite"
|
||||
@@ -31,21 +29,6 @@ type databaseSchemaInfo struct {
|
||||
StepsRequired uint
|
||||
}
|
||||
|
||||
// PreExecute validates the environment before executing the migration.
|
||||
// It returns an error if the migration cannot be performed.
|
||||
func (s *MigrateJob) PreExecute() error {
|
||||
// ensure backup directory exists and is writable
|
||||
backupDir := s.Config.GetBackupDirectoryPathOrDefault()
|
||||
if backupDir != "" {
|
||||
if err := fsutil.EnsureDir(backupDir); err != nil {
|
||||
logger.Errorf("error ensuring backup directory exists: %s", err)
|
||||
logger.Warnf("Backup directory (%s) must be modified to a valid directory or removed from the config file", config.BackupDirectoryPath)
|
||||
return fmt.Errorf("error creating backup directory: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error {
|
||||
schemaInfo, err := s.required()
|
||||
if err != nil {
|
||||
|
||||
@@ -283,10 +283,8 @@ func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress
|
||||
|
||||
for f := range j.fileQueue {
|
||||
logger.Tracef("Processing queued file %s", f.Path)
|
||||
if ctx.Err() != nil {
|
||||
// Keep receiving until queueFiles closes the channel; otherwise
|
||||
// the walker can block on send (full buffer) and never finish.
|
||||
continue
|
||||
if err := ctx.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
wg.Add()
|
||||
|
||||
@@ -66,23 +66,6 @@ type Job struct {
|
||||
cancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
// statusCopy returns a copy of the Job with only the fields needed for
|
||||
// status reporting. Internal fields (exec, cancelFunc, outerCtx) are
|
||||
// excluded so that subscription channels don't retain heavy resources.
|
||||
func (j *Job) statusCopy() Job {
|
||||
return Job{
|
||||
ID: j.ID,
|
||||
Status: j.Status,
|
||||
Details: j.Details,
|
||||
Description: j.Description,
|
||||
Progress: j.Progress,
|
||||
StartTime: j.StartTime,
|
||||
EndTime: j.EndTime,
|
||||
AddTime: j.AddTime,
|
||||
Error: j.Error,
|
||||
}
|
||||
}
|
||||
|
||||
// TimeElapsed returns the total time elapsed for the job.
|
||||
// If the EndTime is set, then it uses this to calculate the elapsed time, otherwise it uses time.Now.
|
||||
func (j *Job) TimeElapsed() time.Duration {
|
||||
|
||||
@@ -105,7 +105,7 @@ func (m *Manager) notifyNewJob(j *Job) {
|
||||
for _, s := range m.subscriptions {
|
||||
// don't block if channel is full
|
||||
select {
|
||||
case s.newJob <- j.statusCopy():
|
||||
case s.newJob <- *j:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -232,9 +232,7 @@ func (m *Manager) removeJob(job *Job) {
|
||||
return
|
||||
}
|
||||
|
||||
// release the executor and subtask details so they can be GC'd
|
||||
// while the job remains in the graveyard for status reporting
|
||||
job.exec = nil
|
||||
// clear any subtasks
|
||||
job.Details = nil
|
||||
|
||||
m.queue = append(m.queue[:index], m.queue[index+1:]...)
|
||||
@@ -248,7 +246,7 @@ func (m *Manager) removeJob(job *Job) {
|
||||
for _, s := range m.subscriptions {
|
||||
// don't block if channel is full
|
||||
select {
|
||||
case s.removedJob <- job.statusCopy():
|
||||
case s.removedJob <- *job:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -312,7 +310,8 @@ func (m *Manager) GetJob(id int) *Job {
|
||||
// get from the queue or graveyard
|
||||
_, j := m.getJob(append(m.queue, m.graveyard...), id)
|
||||
if j != nil {
|
||||
jCopy := j.statusCopy()
|
||||
// make a copy of the job and return the pointer
|
||||
jCopy := *j
|
||||
return &jCopy
|
||||
}
|
||||
|
||||
@@ -327,7 +326,8 @@ func (m *Manager) GetQueue() []Job {
|
||||
var ret []Job
|
||||
|
||||
for _, j := range m.queue {
|
||||
ret = append(ret, j.statusCopy())
|
||||
jCopy := *j
|
||||
ret = append(ret, jCopy)
|
||||
}
|
||||
|
||||
return ret
|
||||
@@ -372,7 +372,7 @@ func (m *Manager) notifyJobUpdate(j *Job) {
|
||||
for _, s := range m.subscriptions {
|
||||
// don't block if channel is full
|
||||
select {
|
||||
case s.updatedJob <- j.statusCopy():
|
||||
case s.updatedJob <- *j:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func (tq *TaskQueue) executer(ctx context.Context) {
|
||||
defer tq.wg.Wait()
|
||||
for task := range tq.tasks {
|
||||
if IsCancelled(ctx) {
|
||||
continue // allow channel to continue draining until Close()
|
||||
return
|
||||
}
|
||||
|
||||
tt := task
|
||||
|
||||
@@ -22,7 +22,7 @@ type GroupNamesFinder interface {
|
||||
|
||||
type SceneRelationships struct {
|
||||
PerformerFinder PerformerFinder
|
||||
TagFinder models.TagNameFinder
|
||||
TagFinder models.TagQueryer
|
||||
StudioFinder StudioFinder
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
|
||||
}
|
||||
|
||||
// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.
|
||||
func ScrapedTagHierarchy(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func ScrapedTagHierarchy(ctx context.Context, qb models.TagNameFinder, s *models
|
||||
|
||||
// ScrapedTag matches the provided tag with the tags
|
||||
// in the database and sets the ID field if one is found.
|
||||
func ScrapedTag(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
if s.StoredID != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -197,29 +197,6 @@ func (_m *TagReaderWriter) FindAllDescendants(ctx context.Context, tagID int, ex
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByAlias provides a mock function with given fields: ctx, alias, nocase
|
||||
func (_m *TagReaderWriter) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
|
||||
ret := _m.Called(ctx, alias, nocase)
|
||||
|
||||
var r0 *models.Tag
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Tag); ok {
|
||||
r0 = rf(ctx, alias, nocase)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
|
||||
r1 = rf(ctx, alias, nocase)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByChildTagID provides a mock function with given fields: ctx, childID
|
||||
func (_m *TagReaderWriter) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) {
|
||||
ret := _m.Called(ctx, childID)
|
||||
|
||||
@@ -9,16 +9,9 @@ type TagGetter interface {
|
||||
Find(ctx context.Context, id int) (*Tag, error)
|
||||
}
|
||||
|
||||
type TagNameFinder interface {
|
||||
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
|
||||
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
|
||||
FindByAlias(ctx context.Context, alias string, nocase bool) (*Tag, error)
|
||||
}
|
||||
|
||||
// TagFinder provides methods to find tags.
|
||||
type TagFinder interface {
|
||||
TagGetter
|
||||
TagNameFinder
|
||||
FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)
|
||||
FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)
|
||||
FindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error)
|
||||
@@ -30,6 +23,8 @@ type TagFinder interface {
|
||||
FindByGroupID(ctx context.Context, groupID int) ([]*Tag, error)
|
||||
FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error)
|
||||
FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error)
|
||||
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
|
||||
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
|
||||
FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error)
|
||||
FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error)
|
||||
}
|
||||
|
||||
@@ -456,7 +456,7 @@ type FilenameParserRepository struct {
|
||||
Performer PerformerNamesFinder
|
||||
Studio models.StudioQueryer
|
||||
Group GroupNameFinder
|
||||
Tag models.TagNameFinder
|
||||
Tag models.TagQueryer
|
||||
}
|
||||
|
||||
func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository {
|
||||
@@ -599,7 +599,7 @@ func (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, gro
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagNameFinder, tagName string) *models.Tag {
|
||||
func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagQueryer, tagName string) *models.Tag {
|
||||
// massage the tag name
|
||||
tagName = delimiterRE.ReplaceAllString(tagName, " ")
|
||||
|
||||
@@ -638,7 +638,7 @@ func (p *FilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFin
|
||||
}
|
||||
}
|
||||
|
||||
func (p *FilenameParser) setTags(ctx context.Context, qb models.TagNameFinder, h sceneHolder, result *models.SceneParserResult) {
|
||||
func (p *FilenameParser) setTags(ctx context.Context, qb models.TagQueryer, h sceneHolder, result *models.SceneParserResult) {
|
||||
// query for each performer
|
||||
tagsSet := make(map[int]bool)
|
||||
for _, tagName := range h.tags {
|
||||
|
||||
@@ -70,7 +70,6 @@ type StudioFinder interface {
|
||||
|
||||
type TagFinder interface {
|
||||
models.TagGetter
|
||||
models.TagNameFinder
|
||||
models.TagAutoTagQueryer
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
)
|
||||
|
||||
func postProcessTags(ctx context.Context, tqb models.TagNameFinder, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) {
|
||||
func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) {
|
||||
ret = make([]*models.ScrapedTag, 0, len(scrapedTags))
|
||||
|
||||
for _, t := range scrapedTags {
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"gopkg.in/guregu/null.v4"
|
||||
)
|
||||
|
||||
func pre84(ctx context.Context, db *sqlx.DB) error {
|
||||
logger.Info("Running pre-migration for schema version 84")
|
||||
func post84(ctx context.Context, db *sqlx.DB) error {
|
||||
logger.Info("Running post-migration for schema version 84")
|
||||
|
||||
m := schema84Migrator{
|
||||
migrator: migrator{
|
||||
@@ -36,23 +36,6 @@ func pre84(ctx context.Context, db *sqlx.DB) error {
|
||||
return fmt.Errorf("fixing incorrect parent folders: %w", err)
|
||||
}
|
||||
|
||||
if err := m.deduplicateFolders(ctx); err != nil {
|
||||
return fmt.Errorf("deduplicating folders: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func post84(ctx context.Context, db *sqlx.DB) error {
|
||||
logger.Info("Running post-migration for schema version 84")
|
||||
|
||||
m := schema84Migrator{
|
||||
migrator: migrator{
|
||||
db: db,
|
||||
},
|
||||
folderCache: make(map[string]folderInfo),
|
||||
}
|
||||
|
||||
if err := m.migrateFolders(ctx); err != nil {
|
||||
return fmt.Errorf("migrating folders: %w", err)
|
||||
}
|
||||
@@ -205,7 +188,7 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string,
|
||||
logger.Debugf("%s doesn't exist. Creating new folder entry...", path)
|
||||
|
||||
// we need to set basename to path, which will be addressed in the next step
|
||||
const insertSQL = "INSERT INTO `folders` (`path`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?)"
|
||||
const insertSQL = "INSERT INTO `folders` (`path`,`basename`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)"
|
||||
|
||||
var parentFolderID null.Int
|
||||
if parentID != nil {
|
||||
@@ -213,7 +196,7 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string,
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
result, err := tx.Exec(insertSQL, path, parentFolderID, time.Time{}, now, now)
|
||||
result, err := tx.Exec(insertSQL, path, path, parentFolderID, time.Time{}, now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating folder %s: %w", path, err)
|
||||
}
|
||||
@@ -281,6 +264,11 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
|
||||
continue
|
||||
}
|
||||
|
||||
if !logged {
|
||||
logger.Info("Fixing folders with incorrect parent folder assignments...")
|
||||
logged = true
|
||||
}
|
||||
|
||||
correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting/creating correct parent for folder %d %q: %w", id, p, err)
|
||||
@@ -290,11 +278,6 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
|
||||
continue
|
||||
}
|
||||
|
||||
if !logged {
|
||||
logger.Info("Fixing folders with incorrect parent folder assignments...")
|
||||
logged = true
|
||||
}
|
||||
|
||||
logger.Debugf("Fixing folder %d %q: changing parent_folder_id from %d to %d", id, p, parentFolderID, *correctParentID)
|
||||
|
||||
_, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *correctParentID, id)
|
||||
@@ -326,136 +309,6 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
|
||||
return nil
|
||||
}
|
||||
|
||||
// deduplicateFolders finds folders that would have the same (parent_folder_id, basename) after
|
||||
// migrateFolders sets basename = filepath.Base(path), and merges the duplicates.
|
||||
// This can happen when the database contains entries for the same physical folder with different
|
||||
// path representations (e.g., mixed separators like "\data/movies" vs "\data\movies" on Windows).
|
||||
func (m *schema84Migrator) deduplicateFolders(ctx context.Context) error {
|
||||
for {
|
||||
n, err := m.deduplicateFoldersPass(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// repeat until no more duplicates are found, since merging child folders
|
||||
// from a duplicate parent into the canonical parent may create new conflicts
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *schema84Migrator) deduplicateFoldersPass(ctx context.Context) (int, error) {
|
||||
type folderRow struct {
|
||||
ID int `db:"id"`
|
||||
Path string `db:"path"`
|
||||
ParentFolderID int `db:"parent_folder_id"`
|
||||
}
|
||||
|
||||
var folders []folderRow
|
||||
if err := m.db.SelectContext(ctx, &folders,
|
||||
"SELECT id, path, parent_folder_id FROM folders WHERE parent_folder_id IS NOT NULL ORDER BY id"); err != nil {
|
||||
return 0, fmt.Errorf("loading folders: %w", err)
|
||||
}
|
||||
|
||||
// group by (parent_folder_id, computed basename)
|
||||
type groupKey struct {
|
||||
parentID int
|
||||
basename string
|
||||
}
|
||||
groups := make(map[groupKey][]folderRow)
|
||||
for _, f := range folders {
|
||||
key := groupKey{
|
||||
parentID: f.ParentFolderID,
|
||||
basename: filepath.Base(f.Path),
|
||||
}
|
||||
groups[key] = append(groups[key], f)
|
||||
}
|
||||
|
||||
deduped := 0
|
||||
for _, group := range groups {
|
||||
if len(group) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
if deduped == 0 {
|
||||
logger.Info("Deduplicating folders with conflicting basenames...")
|
||||
}
|
||||
|
||||
// prefer the folder whose path is already normalized for the current OS,
|
||||
// falling back to the newest entry (highest ID) since it's most likely
|
||||
// from the current filesystem
|
||||
keep := group[len(group)-1]
|
||||
for _, f := range group {
|
||||
if f.Path == filepath.Clean(f.Path) {
|
||||
keep = f
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, dup := range group {
|
||||
if dup.ID == keep.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Infof("Merging duplicate folder %d %q into folder %d %q", dup.ID, dup.Path, keep.ID, keep.Path)
|
||||
|
||||
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
|
||||
return m.mergeFolder(tx, keep.ID, dup.ID)
|
||||
}); err != nil {
|
||||
return 0, fmt.Errorf("merging folder %d into %d: %w", dup.ID, keep.ID, err)
|
||||
}
|
||||
|
||||
deduped++
|
||||
}
|
||||
}
|
||||
|
||||
if deduped > 0 {
|
||||
logger.Infof("Deduplicated %d folder entries", deduped)
|
||||
}
|
||||
|
||||
return deduped, nil
|
||||
}
|
||||
|
||||
func (m *schema84Migrator) mergeFolder(tx *sqlx.Tx, keepID, dupID int) error {
|
||||
// Re-parent child folders from the duplicate to the canonical folder.
|
||||
// At this point basenames are still full paths (unique), so this won't cause
|
||||
// UNIQUE constraint violations on (parent_folder_id, basename).
|
||||
if _, err := tx.Exec("UPDATE folders SET parent_folder_id = ? WHERE parent_folder_id = ?", keepID, dupID); err != nil {
|
||||
return fmt.Errorf("re-parenting child folders: %w", err)
|
||||
}
|
||||
|
||||
// re-parent any files under the duplicate folder to the canonical folder.
|
||||
if _, err := tx.Exec("UPDATE files SET parent_folder_id = ? WHERE parent_folder_id = ?", keepID, dupID); err != nil {
|
||||
return fmt.Errorf("re-parenting files: %w", err)
|
||||
}
|
||||
|
||||
// delete the duplicate folder entry only if it is not referenced by any galleries
|
||||
var count int
|
||||
if err := tx.Get(&count, "SELECT COUNT(*) FROM galleries WHERE folder_id = ?", dupID); err != nil {
|
||||
return fmt.Errorf("checking for gallery references: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
logger.Warnf("Duplicate folder %d is still referenced by %d galleries. Orphaning instead of deleting.", dupID, count)
|
||||
|
||||
// Orphan the stale duplicate folder by clearing its parent so the UNIQUE
|
||||
// constraint on (parent_folder_id, basename) won't be violated when
|
||||
// migrateFolders sets basenames. Any stale file entries under it are left
|
||||
// untouched — the clean task will handle them on the next scan.
|
||||
if _, err := tx.Exec("UPDATE folders SET parent_folder_id = NULL WHERE id = ?", dupID); err != nil {
|
||||
return fmt.Errorf("orphaning duplicate folder: %w", err)
|
||||
}
|
||||
} else {
|
||||
// delete the duplicate folder entry
|
||||
if _, err := tx.Exec("DELETE FROM folders WHERE id = ?", dupID); err != nil {
|
||||
return fmt.Errorf("deleting duplicate folder: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
|
||||
const (
|
||||
limit = 1000
|
||||
@@ -528,6 +381,5 @@ func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func init() {
|
||||
sqlite.RegisterPreMigration(84, pre84)
|
||||
sqlite.RegisterPostMigration(84, post84)
|
||||
}
|
||||
@@ -416,18 +416,6 @@ func (qb *TagStore) find(ctx context.Context, id int) (*models.Tag, error) {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *TagStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Tag, error) {
|
||||
table := qb.table()
|
||||
|
||||
q := qb.selectDataset().Prepared(true).Where(
|
||||
table.Col(idColumn).Eq(
|
||||
sq,
|
||||
),
|
||||
)
|
||||
|
||||
return qb.getMany(ctx, q)
|
||||
}
|
||||
|
||||
// returns nil, sql.ErrNoRows if not found
|
||||
func (qb *TagStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Tag, error) {
|
||||
ret, err := qb.getMany(ctx, q)
|
||||
@@ -591,27 +579,6 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *TagStore) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
|
||||
where := fmt.Sprintf("%s = ?", tagAliasColumn)
|
||||
if nocase {
|
||||
where += " COLLATE NOCASE"
|
||||
}
|
||||
sq := dialect.From(tagsAliasesJoinTable).Select(
|
||||
tagsAliasesJoinTable.Col(tagIDColumn),
|
||||
).Prepared(true).Where(goqu.L(where, alias)).Limit(1)
|
||||
ret, err := qb.findBySubquery(ctx, sq)
|
||||
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return ret[0], nil
|
||||
}
|
||||
|
||||
func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) {
|
||||
sq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where(
|
||||
tagsStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID),
|
||||
|
||||
@@ -100,24 +100,6 @@ func TestTagFindByName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestTagFindByAlias(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
tqb := db.Tag
|
||||
|
||||
alias := getTagStringValue(tagIdxWithScene, "Alias")
|
||||
|
||||
tag, err := tqb.FindByAlias(ctx, alias, false)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error finding tags: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Equal(t, tagIDs[tagIdxWithScene], tag.ID)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestTagQueryIgnoreAutoTag(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
ignoreAutoTag := true
|
||||
|
||||
@@ -6,24 +6,50 @@ import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func ByName(ctx context.Context, qb models.TagNameFinder, name string) (*models.Tag, error) {
|
||||
const nocase = true
|
||||
ret, err := qb.FindByName(ctx, name, nocase)
|
||||
func ByName(ctx context.Context, qb models.TagQueryer, name string) (*models.Tag, error) {
|
||||
f := &models.TagFilterType{
|
||||
Name: &models.StringCriterionInput{
|
||||
Value: name,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
|
||||
pp := 1
|
||||
ret, count, err := qb.Query(ctx, f, &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
if count > 0 {
|
||||
return ret[0], nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func ByAlias(ctx context.Context, qb models.TagNameFinder, alias string) (*models.Tag, error) {
|
||||
const nocase = true
|
||||
ret, err := qb.FindByAlias(ctx, alias, nocase)
|
||||
func ByAlias(ctx context.Context, qb models.TagQueryer, alias string) (*models.Tag, error) {
|
||||
f := &models.TagFilterType{
|
||||
Aliases: &models.StringCriterionInput{
|
||||
Value: alias,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
|
||||
pp := 1
|
||||
ret, count, err := qb.Query(ctx, f, &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
if count > 0 {
|
||||
return ret[0], nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (e *InvalidTagHierarchyError) Error() string {
|
||||
|
||||
// EnsureTagNameUnique returns an error if the tag name provided
|
||||
// is used as a name or alias of another existing tag.
|
||||
func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagNameFinder) error {
|
||||
func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagQueryer) error {
|
||||
// ensure name is unique
|
||||
sameNameTag, err := ByName(ctx, qb, name)
|
||||
if err != nil {
|
||||
@@ -71,7 +71,7 @@ func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.Tag
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagNameFinder) error {
|
||||
func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagQueryer) error {
|
||||
for _, a := range aliases {
|
||||
if err := EnsureTagNameUnique(ctx, id, a, qb); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,60 +1,71 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type tagNameFinderMock struct {
|
||||
existingTags []*models.Tag
|
||||
}
|
||||
|
||||
func (m tagNameFinderMock) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) {
|
||||
for _, n := range m.existingTags {
|
||||
if n.Name == name {
|
||||
return n, nil
|
||||
}
|
||||
func nameFilter(n string) *models.TagFilterType {
|
||||
return &models.TagFilterType{
|
||||
Name: &models.StringCriterionInput{
|
||||
Value: n,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m tagNameFinderMock) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Tag, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m tagNameFinderMock) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
|
||||
for _, n := range m.existingTags {
|
||||
for _, a := range n.Aliases.List() {
|
||||
if a == alias {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
func aliasFilter(n string) *models.TagFilterType {
|
||||
return &models.TagFilterType{
|
||||
Aliases: &models.StringCriterionInput{
|
||||
Value: n,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestEnsureAliasesUnique(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
const (
|
||||
name1 = "name 1"
|
||||
name2 = "name 2"
|
||||
name3 = "name 3"
|
||||
alias1 = "alias 1"
|
||||
newAlias = "new alias"
|
||||
)
|
||||
|
||||
tagMock := tagNameFinderMock{
|
||||
existingTags: []*models.Tag{
|
||||
{Name: name1, Aliases: models.NewRelatedStrings([]string{})},
|
||||
{Name: name2, Aliases: models.NewRelatedStrings([]string{})},
|
||||
{Name: name3, Aliases: models.NewRelatedStrings([]string{newAlias})},
|
||||
},
|
||||
existing2 := models.Tag{
|
||||
ID: 2,
|
||||
Name: name2,
|
||||
}
|
||||
|
||||
pp := 1
|
||||
findFilter := &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
}
|
||||
|
||||
// name1 matches existing1 name - ok
|
||||
// EnsureAliasesUnique calls EnsureTagNameUnique.
|
||||
// EnsureTagNameUnique calls ByName then ByAlias.
|
||||
|
||||
// Case 1: valid alias
|
||||
// ByName "alias 1" -> nil
|
||||
// ByAlias "alias 1" -> nil
|
||||
db.Tag.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil)
|
||||
db.Tag.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil)
|
||||
|
||||
// Case 2: alias duplicates existing2 name
|
||||
// ByName "name 2" -> existing2
|
||||
db.Tag.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Tag{&existing2}, 1, nil)
|
||||
|
||||
// Case 3: alias duplicates existing2 alias
|
||||
// ByName "new alias" -> nil
|
||||
// ByAlias "new alias" -> existing2
|
||||
db.Tag.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil)
|
||||
db.Tag.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Tag{&existing2}, 1, nil)
|
||||
|
||||
tests := []struct {
|
||||
tName string
|
||||
id int
|
||||
@@ -63,12 +74,12 @@ func TestEnsureAliasesUnique(t *testing.T) {
|
||||
}{
|
||||
{"valid alias", 1, []string{alias1}, nil},
|
||||
{"alias duplicates other name", 1, []string{name2}, &NameExistsError{name2}},
|
||||
{"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, tagMock.existingTags[2].Name}},
|
||||
{"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.tName, func(t *testing.T) {
|
||||
got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, tagMock)
|
||||
got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, db.Tag)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export const MarkerWallItem: React.FC<
|
||||
divStyle.top = props.top;
|
||||
}
|
||||
|
||||
const handleClick = function (event: React.MouseEvent) {
|
||||
var handleClick = function handleClick(event: React.MouseEvent) {
|
||||
if (props.selecting && props.onSelectedChanged) {
|
||||
props.onSelectedChanged(!props.selected, event.shiftKey);
|
||||
event.preventDefault();
|
||||
@@ -131,8 +131,7 @@ export const MarkerWallItem: React.FC<
|
||||
alt={props.photo.alt}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
// having a click handler here results in multiple calls to handleClick
|
||||
// due to having the same click handler on the parent div
|
||||
onClick={handleClick}
|
||||
onError={() => {
|
||||
props.photo.onError?.(props.photo);
|
||||
}}
|
||||
|
||||
@@ -70,7 +70,7 @@ export const SceneWallItem: React.FC<
|
||||
divStyle.top = props.top;
|
||||
}
|
||||
|
||||
const handleClick = function (event: React.MouseEvent) {
|
||||
var handleClick = function handleClick(event: React.MouseEvent) {
|
||||
if (props.selecting && props.onSelectedChanged) {
|
||||
props.onSelectedChanged(!props.selected, event.shiftKey);
|
||||
event.preventDefault();
|
||||
@@ -96,8 +96,7 @@ export const SceneWallItem: React.FC<
|
||||
alt: props.photo.alt,
|
||||
onMouseEnter: () => setActive(true),
|
||||
onMouseLeave: () => setActive(false),
|
||||
// having a click handler here results in multiple calls to handleClick
|
||||
// due to having the same click handler on the parent div
|
||||
onClick: handleClick,
|
||||
onError: () => {
|
||||
props.photo.onError?.(props.photo);
|
||||
},
|
||||
|
||||
@@ -51,10 +51,6 @@
|
||||
flex-direction: column;
|
||||
overflow-wrap: anywhere;
|
||||
width: 100%;
|
||||
|
||||
.optional-field-content {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.original-scene-details {
|
||||
@@ -282,10 +278,6 @@
|
||||
.form-check {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p.lead {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.StudioTagger,
|
||||
|
||||
@@ -5,18 +5,14 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import {
|
||||
faCheck,
|
||||
faExclamationTriangle,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { excludeFields } from "src/utils/data";
|
||||
import { StashIDPill } from "src/components/Shared/StashID";
|
||||
|
||||
interface ITagModalProps {
|
||||
tag: GQL.ScrapedTag;
|
||||
tag: GQL.ScrapedSceneTagDataFragment;
|
||||
modalVisible: boolean;
|
||||
closeModal: () => void;
|
||||
onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void;
|
||||
@@ -182,15 +178,6 @@ const TagModal: React.FC<ITagModalProps> = ({
|
||||
// force create if there is no current parent tag and parent tag is not excluded
|
||||
const mustCreateParent = true;
|
||||
|
||||
// warn the user if the parent tag does not have a remote_site_id,
|
||||
// which means it won't be automatically linked to the source tag
|
||||
const missingStashIDWarning = !tag.parent.remote_site_id && (
|
||||
<p className="lead">
|
||||
<Icon icon={faExclamationTriangle} className="text-warning" />
|
||||
<FormattedMessage id="tag_tagger.parent_tag_no_remote_site_id_warning" />
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 mt-4">
|
||||
@@ -205,7 +192,6 @@ const TagModal: React.FC<ITagModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{maybeRenderParentTagDetails()}
|
||||
{missingStashIDWarning}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
|
||||
const [modalTag, setModalTag] = useState<
|
||||
| {
|
||||
existingTag: GQL.TagListDataFragment;
|
||||
scrapedTag: GQL.ScrapedTag;
|
||||
scrapedTag: GQL.ScrapedSceneTagDataFragment;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
||||
@@ -290,7 +290,7 @@ export const FilteredTagList = PatchComponent(
|
||||
showModal(
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
tags: {
|
||||
studios: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: all,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
### 🎨 Improvements
|
||||
|
||||
* **[0.31.1]** Added warning when creating a parent tag using the tag tagger where the parent tag has no remote site id. ([#6805](https://github.com/stashapp/stash/pull/6805))
|
||||
* Sidebars are now used for lists of galleries ([#6157](https://github.com/stashapp/stash/pull/6157)), images ([#6607](https://github.com/stashapp/stash/pull/6607)), groups ([#6573](https://github.com/stashapp/stash/pull/6573)), performers ([#6547](https://github.com/stashapp/stash/pull/6547)), studios ([#6549](https://github.com/stashapp/stash/pull/6549)), tags ([#6610](https://github.com/stashapp/stash/pull/6610)), and scene markers ([#6603](https://github.com/stashapp/stash/pull/6603)).
|
||||
* Added folder sidebar criterion option for scenes, images and galleries. ([#6636](https://github.com/stashapp/stash/pull/6636))
|
||||
* Custom field support has been added to scenes ([#6584](https://github.com/stashapp/stash/pull/6584)), galleries ([#6592](https://github.com/stashapp/stash/pull/6592)), images ([#6598](https://github.com/stashapp/stash/pull/6598)), groups ([#6596](https://github.com/stashapp/stash/pull/6596)) studios ([#6156](https://github.com/stashapp/stash/pull/6156)) and tags ([#6546](https://github.com/stashapp/stash/pull/6546)).
|
||||
@@ -64,15 +63,6 @@
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
* **[0.31.1]** Fixed tag export outputting studios instead of tags. ([#6819](https://github.com/stashapp/stash/pull/6819))
|
||||
* **[0.31.1]** Fixed memory leak in scanning process. ([#6796](https://github.com/stashapp/stash/pull/6796))
|
||||
* **[0.31.1]** Schema migration 84 now attempts to de-duplicate folder entries to prevent unique constraint violations. ([#6792](https://github.com/stashapp/stash/pull/6792))
|
||||
* **[0.31.1]** Fixed issue where navigating to a scene from the wall view on the scene or marker list page would require clicking Back twice to return to the previous page. ([#6803](https://github.com/stashapp/stash/pull/6803))
|
||||
* **[0.31.1]** Page is now reset when changing the selected folder in the folder sidebar filter. ([#6804](https://github.com/stashapp/stash/pull/6804))
|
||||
* **[0.31.1]** Fixed stash ID pill overflowing on mobile viewports. ([#6807](https://github.com/stashapp/stash/pull/6807))
|
||||
* **[0.31.1]** Migration process now attempts to create the backup directory if it does not exist. ([#6808](https://github.com/stashapp/stash/pull/6808))
|
||||
* **[0.31.1]** Fixed tag uniqueness check incorrectly interpreting `_` as a wildcard. ([#6809](https://github.com/stashapp/stash/pull/6809))
|
||||
* **[0.31.1]** Fixed websocket connection error when sending messages containing certain unicode sequences. ([#6810](https://github.com/stashapp/stash/pull/6810))
|
||||
* Fixed certain unicode characters in library path causing panic in scan task. ([#6431](https://github.com/stashapp/stash/pull/6431), [#6589](https://github.com/stashapp/stash/pull/6589), [#6635](https://github.com/stashapp/stash/pull/6635))
|
||||
* Fixed bad network path error preventing rename detection during scanning. ([#6680](https://github.com/stashapp/stash/pull/6680))
|
||||
* Fixed duplicate files in zips being incorrectly reported as renames. ([#6493](https://github.com/stashapp/stash/pull/6493))
|
||||
|
||||
@@ -8,10 +8,10 @@ Ensure the caption files follow these naming conventions:
|
||||
|
||||
## Scene
|
||||
|
||||
- {scene_file_name}.{language_code}.{ext}
|
||||
- {scene_file_name}.{ext}
|
||||
- {scene_file_name}.{language_code}.ext
|
||||
- {scene_file_name}.ext
|
||||
|
||||
Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (2 letters) standard and `{ext}` is the file extension. Captions files without a language code will be labeled as Unknown in the video player but will work fine.
|
||||
Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (2 letters) standard and `ext` is the file extension. Captions files without a language code will be labeled as Unknown in the video player but will work fine.
|
||||
|
||||
Scenes with captions can be filtered with the `captions` criterion.
|
||||
|
||||
|
||||
@@ -1638,7 +1638,6 @@
|
||||
"network_error": "Network Error",
|
||||
"no_results_found": "No results found.",
|
||||
"number_of_tags_will_be_processed": "{tag_count} tags will be processed",
|
||||
"parent_tag_no_remote_site_id_warning": "Parent tag does not have a remote site ID, and will not be linked to the stash-box instance.",
|
||||
"query_all_tags_in_the_database": "All tags in the database",
|
||||
"refresh_tagged_tags": "Refresh tagged tags",
|
||||
"refreshing_will_update_the_data": "Refreshing will update the data of any tagged tags from the stash-box instance.",
|
||||
|
||||
@@ -510,7 +510,6 @@ export class ListFilterModel {
|
||||
public setCriteria(criteria: Criterion[]) {
|
||||
const ret = this.clone();
|
||||
ret.criteria = criteria;
|
||||
ret.currentPage = 1; // reset to first page
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user