Maintain saved filters in full export/import (#5465)

* Remove ellipsis from full export button
This commit is contained in:
WithoutPants 2024-11-12 16:59:28 +11:00 committed by GitHub
parent 41d1b45fb9
commit a18c538c1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 484 additions and 9 deletions

View File

@ -42,3 +42,7 @@ func (jp *jsonUtils) saveGallery(fn string, gallery *jsonschema.Gallery) error {
func (jp *jsonUtils) saveFile(fn string, file jsonschema.DirEntry) error {
return jsonschema.SaveFileFile(filepath.Join(jp.json.Files, fn), file)
}
func (jp *jsonUtils) saveSavedFilter(fn string, savedFilter *jsonschema.SavedFilter) error {
return jsonschema.SaveSavedFilterFile(filepath.Join(jp.json.SavedFilters, fn), savedFilter)
}

View File

@ -23,6 +23,7 @@ import (
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/savedfilter"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
@ -176,6 +177,7 @@ func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) {
t.ExportPerformers(ctx, workerCount)
t.ExportStudios(ctx, workerCount)
t.ExportTags(ctx, workerCount)
t.ExportSavedFilters(ctx, workerCount)
return nil
})
@ -1186,3 +1188,62 @@ func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobCha
}
}
}
func (t *ExportTask) ExportSavedFilters(ctx context.Context, workers int) {
// don't export saved filters unless we're doing a full export
if !t.full {
return
}
var wg sync.WaitGroup
reader := t.repository.SavedFilter
var filters []*models.SavedFilter
var err error
filters, err = reader.All(ctx)
if err != nil {
logger.Errorf("[saved filters] failed to fetch saved filters: %v", err)
}
logger.Info("[saved filters] exporting")
startTime := time.Now()
jobCh := make(chan *models.SavedFilter, workers*2) // make a buffered channel to feed workers
for w := 0; w < workers; w++ { // create export Saved Filter workers
wg.Add(1)
go t.exportSavedFilter(ctx, &wg, jobCh)
}
for i, savedFilter := range filters {
index := i + 1
logger.Progressf("[saved filters] %d of %d", index, len(filters))
jobCh <- savedFilter // feed workers
}
close(jobCh)
wg.Wait()
logger.Infof("[saved filters] export complete in %s. %d workers used.", time.Since(startTime), workers)
}
func (t *ExportTask) exportSavedFilter(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.SavedFilter) {
defer wg.Done()
for thisFilter := range jobChan {
newJSON, err := savedfilter.ToJSON(ctx, thisFilter)
if err != nil {
logger.Errorf("[saved filter] <%s> error getting saved filter JSON: %v", thisFilter.Name, err)
continue
}
fn := newJSON.Filename()
if err := t.json.saveSavedFilter(fn, newJSON); err != nil {
logger.Errorf("[saved filter] <%s> failed to save json: %v", fn, err)
}
}
}

View File

@ -20,6 +20,7 @@ import (
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/savedfilter"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/studio"
"github.com/stashapp/stash/pkg/tag"
@ -124,6 +125,7 @@ func (t *ImportTask) Start(ctx context.Context) {
}
}
t.ImportSavedFilters(ctx)
t.ImportTags(ctx)
t.ImportPerformers(ctx)
t.ImportStudios(ctx)
@ -779,3 +781,53 @@ func (t *ImportTask) ImportImages(ctx context.Context) {
logger.Info("[images] import complete")
}
func (t *ImportTask) ImportSavedFilters(ctx context.Context) {
logger.Info("[saved filters] importing")
path := t.json.json.SavedFilters
files, err := os.ReadDir(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
logger.Errorf("[saved filters] failed to read saved filters directory: %v", err)
}
return
}
r := t.repository
for i, fi := range files {
index := i + 1
savedFilterJSON, err := jsonschema.LoadSavedFilterFile(filepath.Join(path, fi.Name()))
if err != nil {
logger.Errorf("[saved filters] failed to read json: %v", err)
continue
}
logger.Progressf("[saved filters] %d of %d", index, len(files))
if err := r.WithTxn(ctx, func(ctx context.Context) error {
return t.importSavedFilter(ctx, savedFilterJSON)
}); err != nil {
logger.Errorf("[saved filters] <%s> failed to import: %v", fi.Name(), err)
continue
}
}
logger.Info("[saved filters] import complete")
}
func (t *ImportTask) importSavedFilter(ctx context.Context, savedFilterJSON *jsonschema.SavedFilter) error {
importer := &savedfilter.Importer{
ReaderWriter: t.repository.SavedFilter,
Input: *savedFilterJSON,
MissingRefBehaviour: t.MissingRefBehaviour,
}
if err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,31 @@
package jsonschema
import (
"fmt"
"os"
jsoniter "github.com/json-iterator/go"
)
func loadFile[T any](filePath string) (*T, error) {
var ret T
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
var json = jsoniter.ConfigCompatibleWithStandardLibrary
jsonParser := json.NewDecoder(file)
err = jsonParser.Decode(&ret)
if err != nil {
return nil, err
}
return &ret, nil
}
func saveFile[T any](filePath string, obj *T) error {
if obj == nil {
return fmt.Errorf("object must not be nil")
}
return marshalToFile(filePath, obj)
}

View File

@ -0,0 +1,27 @@
package jsonschema
import (
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/models"
)
type SavedFilter struct {
Mode models.FilterMode `db:"mode" json:"mode"`
Name string `db:"name" json:"name"`
FindFilter *models.FindFilterType `json:"find_filter"`
ObjectFilter map[string]interface{} `json:"object_filter"`
UIOptions map[string]interface{} `json:"ui_options"`
}
func (s SavedFilter) Filename() string {
ret := fsutil.SanitiseBasename(s.Name + "_" + s.Mode.String())
return ret + ".json"
}
func LoadSavedFilterFile(filePath string) (*SavedFilter, error) {
return loadFile[SavedFilter](filePath)
}
func SaveSavedFilterFile(filePath string, image *SavedFilter) error {
return saveFile[SavedFilter](filePath, image)
}

View File

@ -20,6 +20,7 @@ type JSONPaths struct {
Tags string
Groups string
Files string
SavedFilters string
}
func newJSONPaths(baseDir string) *JSONPaths {
@ -34,6 +35,7 @@ func newJSONPaths(baseDir string) *JSONPaths {
jp.Groups = filepath.Join(baseDir, "movies")
jp.Tags = filepath.Join(baseDir, "tags")
jp.Files = filepath.Join(baseDir, "files")
jp.SavedFilters = filepath.Join(baseDir, "saved_filters")
return &jp
}
@ -52,6 +54,7 @@ func EmptyJSONDirs(baseDir string) {
_ = fsutil.EmptyDir(jsonPaths.Groups)
_ = fsutil.EmptyDir(jsonPaths.Tags)
_ = fsutil.EmptyDir(jsonPaths.Files)
_ = fsutil.EmptyDir(jsonPaths.SavedFilters)
}
func EnsureJSONDirs(baseDir string) {
@ -83,4 +86,7 @@ func EnsureJSONDirs(baseDir string) {
if err := fsutil.EnsureDir(jsonPaths.Files); err != nil {
logger.Warnf("couldn't create directories for Files: %v", err)
}
if err := fsutil.EnsureDir(jsonPaths.SavedFilters); err != nil {
logger.Warnf("couldn't create directories for Saved Filters: %v", err)
}
}

19
pkg/savedfilter/export.go Normal file
View File

@ -0,0 +1,19 @@
package savedfilter
import (
"context"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
)
// ToJSON converts a SavedFilter object into its JSON equivalent.
func ToJSON(ctx context.Context, filter *models.SavedFilter) (*jsonschema.SavedFilter, error) {
return &jsonschema.SavedFilter{
Name: filter.Name,
Mode: filter.Mode,
FindFilter: filter.FindFilter,
ObjectFilter: filter.ObjectFilter,
UIOptions: filter.UIOptions,
}, nil
}

View File

@ -0,0 +1,91 @@
package savedfilter
import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
"testing"
)
const (
savedFilterID = 1
noImageID = 2
errImageID = 3
errAliasID = 4
withParentsID = 5
errParentsID = 6
)
const (
filterName = "testFilter"
mode = models.FilterModeGalleries
)
var (
findFilter = models.FindFilterType{}
objectFilter = make(map[string]interface{})
uiOptions = make(map[string]interface{})
)
func createSavedFilter(id int) models.SavedFilter {
return models.SavedFilter{
ID: id,
Name: filterName,
Mode: mode,
FindFilter: &findFilter,
ObjectFilter: objectFilter,
UIOptions: uiOptions,
}
}
func createJSONSavedFilter() *jsonschema.SavedFilter {
return &jsonschema.SavedFilter{
Name: filterName,
Mode: mode,
FindFilter: &findFilter,
ObjectFilter: objectFilter,
UIOptions: uiOptions,
}
}
type testScenario struct {
savedFilter models.SavedFilter
expected *jsonschema.SavedFilter
err bool
}
var scenarios []testScenario
func initTestTable() {
scenarios = []testScenario{
{
createSavedFilter(savedFilterID),
createJSONSavedFilter(),
false,
},
}
}
func TestToJSON(t *testing.T) {
initTestTable()
db := mocks.NewDatabase()
for i, s := range scenarios {
savedFilter := s.savedFilter
json, err := ToJSON(testCtx, &savedFilter)
switch {
case !s.err && err != nil:
t.Errorf("[%d] unexpected error: %s", i, err.Error())
case s.err && err == nil:
t.Errorf("[%d] expected error not returned", i)
default:
assert.Equal(t, s.expected, json, "[%d]", i)
}
}
db.AssertExpectations(t)
}

60
pkg/savedfilter/import.go Normal file
View File

@ -0,0 +1,60 @@
package savedfilter
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
)
type ImporterReaderWriter interface {
models.SavedFilterWriter
}
type Importer struct {
ReaderWriter ImporterReaderWriter
Input jsonschema.SavedFilter
MissingRefBehaviour models.ImportMissingRefEnum
savedFilter models.SavedFilter
}
func (i *Importer) PreImport(ctx context.Context) error {
i.savedFilter = models.SavedFilter{
Name: i.Input.Name,
Mode: i.Input.Mode,
FindFilter: i.Input.FindFilter,
ObjectFilter: i.Input.ObjectFilter,
UIOptions: i.Input.UIOptions,
}
return nil
}
func (i *Importer) PostImport(ctx context.Context, id int) error {
return nil
}
func (i *Importer) Name() string {
return i.Input.Name
}
func (i *Importer) FindExistingID(ctx context.Context) (*int, error) {
// for now, assume this is only imported in full, so we don't support updating existing filters
return nil, nil
}
func (i *Importer) Create(ctx context.Context) (*int, error) {
err := i.ReaderWriter.Create(ctx, &i.savedFilter)
if err != nil {
return nil, fmt.Errorf("error creating saved filter: %v", err)
}
id := i.savedFilter.ID
return &id, nil
}
func (i *Importer) Update(ctx context.Context, id int) error {
return fmt.Errorf("updating existing saved filters is not supported")
}

View File

@ -0,0 +1,124 @@
package savedfilter
import (
"context"
"errors"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const (
savedFilterNameErr = "savedFilterNameErr"
existingSavedFilterName = "existingSavedFilterName"
existingFilterID = 100
)
var testCtx = context.Background()
func TestImporterName(t *testing.T) {
i := Importer{
Input: jsonschema.SavedFilter{
Name: filterName,
},
}
assert.Equal(t, filterName, i.Name())
}
func TestImporterPreImport(t *testing.T) {
i := Importer{
Input: jsonschema.SavedFilter{
Name: filterName,
},
}
err := i.PreImport(testCtx)
assert.Nil(t, err)
}
func TestImporterPostImport(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
ReaderWriter: db.SavedFilter,
Input: jsonschema.SavedFilter{},
}
err := i.PostImport(testCtx, savedFilterID)
assert.Nil(t, err)
db.AssertExpectations(t)
}
func TestImporterFindExistingID(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
ReaderWriter: db.SavedFilter,
Input: jsonschema.SavedFilter{
Name: filterName,
},
}
id, err := i.FindExistingID(testCtx)
assert.Nil(t, id)
assert.Nil(t, err)
}
func TestCreate(t *testing.T) {
db := mocks.NewDatabase()
savedFilter := models.SavedFilter{
Name: filterName,
}
savedFilterErr := models.SavedFilter{
Name: savedFilterNameErr,
}
i := Importer{
ReaderWriter: db.SavedFilter,
savedFilter: savedFilter,
}
errCreate := errors.New("Create error")
db.SavedFilter.On("Create", testCtx, &savedFilter).Run(func(args mock.Arguments) {
t := args.Get(1).(*models.SavedFilter)
t.ID = savedFilterID
}).Return(nil).Once()
db.SavedFilter.On("Create", testCtx, &savedFilterErr).Return(errCreate).Once()
id, err := i.Create(testCtx)
assert.Equal(t, savedFilterID, *id)
assert.Nil(t, err)
i.savedFilter = savedFilterErr
id, err = i.Create(testCtx)
assert.Nil(t, id)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestUpdate(t *testing.T) {
db := mocks.NewDatabase()
savedFilterErr := models.SavedFilter{
Name: savedFilterNameErr,
}
i := Importer{
ReaderWriter: db.SavedFilter,
savedFilter: savedFilterErr,
}
// Update is not currently supported
err := i.Update(testCtx, existingFilterID)
assert.NotNil(t, err)
}

View File

@ -520,7 +520,7 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
type="submit"
onClick={() => onExport()}
>
<FormattedMessage id="actions.full_export" />
<FormattedMessage id="actions.full_export" />
</Button>
</Setting>