mirror of https://github.com/stashapp/stash.git
Save task options (#4620)
* Support setting nested UI values * Accept partial for configureUI * Send partial UI * Save scan, generate and auto-tag options on change * Send partials in saveUI * Save library task options on change
This commit is contained in:
parent
bf7cb78d6d
commit
7ac7963972
|
@ -352,9 +352,12 @@ type Mutation {
|
|||
# overwrites the entire plugin configuration for the given plugin
|
||||
configurePlugin(plugin_id: ID!, input: Map!): Map!
|
||||
|
||||
# overwrites the entire UI configuration
|
||||
configureUI(input: Map!): Map!
|
||||
# overwrites the UI configuration
|
||||
# if input is provided, then the entire UI configuration is replaced
|
||||
# if partial is provided, then the partial UI configuration is merged into the existing UI configuration
|
||||
configureUI(input: Map, partial: Map): Map!
|
||||
# sets a single UI key value
|
||||
# key is a dot separated path to the value
|
||||
configureUISetting(key: String!, value: Any): Map!
|
||||
|
||||
"Generate and set (or clear) API key"
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrOverriddenConfig = errors.New("cannot set overridden value")
|
||||
|
@ -639,9 +640,19 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPI
|
|||
return newAPIKey, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}, partial map[string]interface{}) (map[string]interface{}, error) {
|
||||
c := config.GetInstance()
|
||||
c.SetUIConfiguration(input)
|
||||
|
||||
if input != nil {
|
||||
c.SetUIConfiguration(input)
|
||||
}
|
||||
|
||||
if partial != nil {
|
||||
// merge partial into existing config
|
||||
existing := c.GetUIConfiguration()
|
||||
utils.MergeMaps(existing, partial)
|
||||
c.SetUIConfiguration(existing)
|
||||
}
|
||||
|
||||
if err := c.Write(); err != nil {
|
||||
return c.GetUIConfiguration(), err
|
||||
|
@ -653,10 +664,10 @@ func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]int
|
|||
func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, value interface{}) (map[string]interface{}, error) {
|
||||
c := config.GetInstance()
|
||||
|
||||
cfg := c.GetUIConfiguration()
|
||||
cfg[key] = value
|
||||
cfg := utils.NestedMap(c.GetUIConfiguration())
|
||||
cfg.Set(key, value)
|
||||
|
||||
return r.ConfigureUI(ctx, cfg)
|
||||
return r.ConfigureUI(ctx, cfg, nil)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ConfigurePlugin(ctx context.Context, pluginID string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NestedMap is a map that supports nested keys.
|
||||
// It is expected that the nested maps are of type map[string]interface{}
|
||||
type NestedMap map[string]interface{}
|
||||
|
||||
func (m NestedMap) Get(key string) (interface{}, bool) {
|
||||
fields := strings.Split(key, ".")
|
||||
|
||||
current := m
|
||||
|
||||
for _, f := range fields[:len(fields)-1] {
|
||||
v, found := current[f]
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
current, _ = v.(map[string]interface{})
|
||||
if current == nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
ret, found := current[fields[len(fields)-1]]
|
||||
return ret, found
|
||||
}
|
||||
|
||||
func (m NestedMap) Set(key string, value interface{}) {
|
||||
fields := strings.Split(key, ".")
|
||||
|
||||
current := m
|
||||
|
||||
for _, f := range fields[:len(fields)-1] {
|
||||
v, ok := current[f].(map[string]interface{})
|
||||
if !ok {
|
||||
v = make(map[string]interface{})
|
||||
current[f] = v
|
||||
}
|
||||
|
||||
current = v
|
||||
}
|
||||
|
||||
current[fields[len(fields)-1]] = value
|
||||
}
|
||||
|
||||
// MergeMaps merges src into dest. If a key exists in both maps, the value from src is used.
|
||||
func MergeMaps(dest map[string]interface{}, src map[string]interface{}) {
|
||||
for k, v := range src {
|
||||
if _, ok := dest[k]; ok {
|
||||
if srcMap, ok := v.(map[string]interface{}); ok {
|
||||
if destMap, ok := dest[k].(map[string]interface{}); ok {
|
||||
MergeMaps(destMap, srcMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dest[k] = v
|
||||
}
|
||||
}
|
|
@ -0,0 +1,218 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNestedMapGet(t *testing.T) {
|
||||
m := NestedMap{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": map[string]interface{}{
|
||||
"baz": "qux",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want interface{}
|
||||
found bool
|
||||
}{
|
||||
{
|
||||
name: "Get a value from a nested map",
|
||||
key: "foo.bar.baz",
|
||||
want: "qux",
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "Get a value from a nested map with a missing key",
|
||||
key: "foo.bar.quux",
|
||||
want: nil,
|
||||
found: false,
|
||||
},
|
||||
{
|
||||
name: "Get a value from a nested map with a missing key",
|
||||
key: "foo.quux.baz",
|
||||
want: nil,
|
||||
found: false,
|
||||
},
|
||||
{
|
||||
name: "Get a value from a nested map with a missing key",
|
||||
key: "quux.bar.baz",
|
||||
want: nil,
|
||||
found: false,
|
||||
},
|
||||
{
|
||||
name: "Get a value from a nested map with a missing key",
|
||||
key: "foo.bar",
|
||||
want: map[string]interface{}{"baz": "qux"},
|
||||
found: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, found := m.Get(tt.key)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("NestedMap.Get() got = %v, want %v", got, tt.want)
|
||||
}
|
||||
if found != tt.found {
|
||||
t.Errorf("NestedMap.Get() found = %v, want %v", found, tt.found)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedMapSet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
existing NestedMap
|
||||
want NestedMap
|
||||
}{
|
||||
{
|
||||
name: "Set a value in a nested map",
|
||||
key: "foo.bar.baz",
|
||||
existing: NestedMap{},
|
||||
want: NestedMap{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": map[string]interface{}{
|
||||
"baz": "qux",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Overwrite existing value",
|
||||
key: "foo.bar",
|
||||
existing: NestedMap{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "old",
|
||||
},
|
||||
},
|
||||
want: NestedMap{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "qux",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Set a value overwriting a primitive with a nested map",
|
||||
key: "foo.bar",
|
||||
existing: NestedMap{
|
||||
"foo": "bar",
|
||||
},
|
||||
want: NestedMap{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "qux",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.existing.Set(tt.key, "qux")
|
||||
if !reflect.DeepEqual(tt.existing, tt.want) {
|
||||
t.Errorf("NestedMap.Set() got = %v, want %v", tt.existing, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMaps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dest map[string]interface{}
|
||||
src map[string]interface{}
|
||||
result map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "Merge two maps",
|
||||
dest: map[string]interface{}{
|
||||
"foo": "bar",
|
||||
},
|
||||
src: map[string]interface{}{
|
||||
"baz": "qux",
|
||||
},
|
||||
result: map[string]interface{}{
|
||||
"foo": "bar",
|
||||
"baz": "qux",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Merge two maps with overlapping keys",
|
||||
dest: map[string]interface{}{
|
||||
"foo": "bar",
|
||||
"baz": "qux",
|
||||
},
|
||||
src: map[string]interface{}{
|
||||
"baz": "quux",
|
||||
},
|
||||
result: map[string]interface{}{
|
||||
"foo": "bar",
|
||||
"baz": "quux",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Merge two maps with overlapping keys and nested maps",
|
||||
dest: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "baz",
|
||||
},
|
||||
},
|
||||
src: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"qux": "quux",
|
||||
},
|
||||
},
|
||||
result: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "baz",
|
||||
"qux": "quux",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Merge two maps with overlapping keys and nested maps",
|
||||
dest: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "baz",
|
||||
},
|
||||
},
|
||||
src: map[string]interface{}{
|
||||
"foo": "qux",
|
||||
},
|
||||
result: map[string]interface{}{
|
||||
"foo": "qux",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Merge two maps with overlapping keys and nested maps",
|
||||
dest: map[string]interface{}{
|
||||
"foo": "qux",
|
||||
},
|
||||
src: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "baz",
|
||||
},
|
||||
},
|
||||
result: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "baz",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
MergeMaps(tt.dest, tt.src)
|
||||
if !reflect.DeepEqual(tt.dest, tt.result) {
|
||||
t.Errorf("NestedMap.Set() got = %v, want %v", tt.dest, tt.result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -22,5 +22,5 @@ extend input SetDefaultFilterInput {
|
|||
}
|
||||
|
||||
extend type Mutation {
|
||||
configureUI(input: Map!): UIConfig!
|
||||
configureUI(input: Map, partial: Map): UIConfig!
|
||||
}
|
||||
|
|
|
@ -36,8 +36,12 @@ mutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) {
|
|||
}
|
||||
}
|
||||
|
||||
mutation ConfigureUI($input: Map!) {
|
||||
configureUI(input: $input)
|
||||
mutation ConfigureUI($input: Map, $partial: Map) {
|
||||
configureUI(input: $input, partial: $partial)
|
||||
}
|
||||
|
||||
mutation ConfigureUISetting($key: String!, $value: Any) {
|
||||
configureUISetting(key: $key, value: $value)
|
||||
}
|
||||
|
||||
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
mutateMetadataScan,
|
||||
mutateMetadataAutoTag,
|
||||
mutateMetadataGenerate,
|
||||
useConfigureDefaults,
|
||||
} from "src/core/StashService";
|
||||
import { withoutTypename } from "src/utils/data";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
|
@ -20,6 +19,7 @@ import { BooleanSetting, Setting, SettingGroup } from "../Inputs";
|
|||
import { ManualLink } from "src/components/Help/context";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useSettings } from "../context";
|
||||
|
||||
interface IAutoTagOptions {
|
||||
options: GQL.AutoTagMetadataInput;
|
||||
|
@ -71,7 +71,9 @@ const AutoTagOptions: React.FC<IAutoTagOptions> = ({
|
|||
export const LibraryTasks: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const [configureDefaults] = useConfigureDefaults();
|
||||
const { ui, saveUI, loading } = useSettings();
|
||||
|
||||
const { taskDefaults } = ui;
|
||||
|
||||
const [dialogOpen, setDialogOpenState] = useState({
|
||||
scan: false,
|
||||
|
@ -111,22 +113,34 @@ export const LibraryTasks: React.FC = () => {
|
|||
const [configRead, setConfigRead] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configuration?.defaults) {
|
||||
if (!configuration?.defaults || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scan, autoTag } = configuration.defaults;
|
||||
|
||||
if (scan) {
|
||||
// prefer UI defaults over system defaults
|
||||
// other defaults should be deprecated
|
||||
if (taskDefaults?.scan) {
|
||||
setScanOptions(taskDefaults.scan);
|
||||
} else if (scan) {
|
||||
setScanOptions(withoutTypename(scan));
|
||||
}
|
||||
if (autoTag) {
|
||||
|
||||
if (taskDefaults?.autoTag) {
|
||||
setAutoTagOptions(taskDefaults.autoTag);
|
||||
} else if (autoTag) {
|
||||
setAutoTagOptions(withoutTypename(autoTag));
|
||||
}
|
||||
|
||||
if (taskDefaults?.generate) {
|
||||
setGenerateOptions(taskDefaults.generate);
|
||||
}
|
||||
|
||||
// combine the defaults with the system preview generation settings
|
||||
// only do this once
|
||||
if (!configRead) {
|
||||
// don't do this if UI had a default
|
||||
if (!configRead && !taskDefaults?.generate) {
|
||||
if (configuration?.defaults.generate) {
|
||||
const { generate } = configuration.defaults;
|
||||
setGenerateOptions(withoutTypename(generate));
|
||||
|
@ -158,7 +172,26 @@ export const LibraryTasks: React.FC = () => {
|
|||
|
||||
setConfigRead(true);
|
||||
}
|
||||
}, [configuration, configRead]);
|
||||
}, [configuration, configRead, taskDefaults, loading]);
|
||||
|
||||
function configureDefaults(partial: Record<string, {}>) {
|
||||
saveUI({ taskDefaults: { ...partial } });
|
||||
}
|
||||
|
||||
function onSetScanOptions(s: GQL.ScanMetadataInput) {
|
||||
configureDefaults({ scan: s });
|
||||
setScanOptions(s);
|
||||
}
|
||||
|
||||
function onSetGenerateOptions(s: GQL.GenerateMetadataInput) {
|
||||
configureDefaults({ generate: s });
|
||||
setGenerateOptions(s);
|
||||
}
|
||||
|
||||
function onSetAutoTagOptions(s: GQL.AutoTagMetadataInput) {
|
||||
configureDefaults({ autoTag: s });
|
||||
setAutoTagOptions(s);
|
||||
}
|
||||
|
||||
function setDialogOpen(s: Partial<DialogOpenState>) {
|
||||
setDialogOpenState((v) => {
|
||||
|
@ -184,14 +217,6 @@ export const LibraryTasks: React.FC = () => {
|
|||
|
||||
async function runScan(paths?: string[]) {
|
||||
try {
|
||||
configureDefaults({
|
||||
variables: {
|
||||
input: {
|
||||
scan: scanOptions,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await mutateMetadataScan({
|
||||
...scanOptions,
|
||||
paths,
|
||||
|
@ -226,14 +251,6 @@ export const LibraryTasks: React.FC = () => {
|
|||
|
||||
async function runAutoTag(paths?: string[]) {
|
||||
try {
|
||||
configureDefaults({
|
||||
variables: {
|
||||
input: {
|
||||
autoTag: autoTagOptions,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await mutateMetadataAutoTag({
|
||||
...autoTagOptions,
|
||||
paths,
|
||||
|
@ -260,14 +277,6 @@ export const LibraryTasks: React.FC = () => {
|
|||
|
||||
async function onGenerateClicked() {
|
||||
try {
|
||||
configureDefaults({
|
||||
variables: {
|
||||
input: {
|
||||
generate: generateOptions,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await mutateMetadataGenerate(generateOptions);
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
|
@ -322,7 +331,7 @@ export const LibraryTasks: React.FC = () => {
|
|||
}
|
||||
collapsible
|
||||
>
|
||||
<ScanOptions options={scanOptions} setOptions={setScanOptions} />
|
||||
<ScanOptions options={scanOptions} setOptions={onSetScanOptions} />
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
|
||||
|
@ -384,7 +393,7 @@ export const LibraryTasks: React.FC = () => {
|
|||
>
|
||||
<AutoTagOptions
|
||||
options={autoTagOptions}
|
||||
setOptions={(o) => setAutoTagOptions(o)}
|
||||
setOptions={onSetAutoTagOptions}
|
||||
/>
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
|
@ -415,7 +424,7 @@ export const LibraryTasks: React.FC = () => {
|
|||
>
|
||||
<GenerateOptions
|
||||
options={generateOptions}
|
||||
setOptions={setGenerateOptions}
|
||||
setOptions={onSetGenerateOptions}
|
||||
/>
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
|
|
|
@ -426,12 +426,12 @@ export const SettingsContext: React.FC = ({ children }) => {
|
|||
type UIConfigInput = GQL.Scalars["Map"]["input"];
|
||||
|
||||
// saves the configuration if no further changes are made after a half second
|
||||
const saveUIConfig = useDebounce(async (input: IUIConfig) => {
|
||||
const saveUIConfig = useDebounce(async (input: Partial<IUIConfig>) => {
|
||||
try {
|
||||
setUpdateSuccess(undefined);
|
||||
await updateUIConfig({
|
||||
variables: {
|
||||
input: input as UIConfigInput,
|
||||
partial: input as UIConfigInput,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -461,13 +461,6 @@ export const SettingsContext: React.FC = ({ children }) => {
|
|||
});
|
||||
|
||||
setPendingUI((current) => {
|
||||
if (!current) {
|
||||
// use full UI object to ensure nothing is wiped
|
||||
return {
|
||||
...ui,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
...input,
|
||||
|
|
|
@ -2301,9 +2301,36 @@ export const useConfigureDefaults = () =>
|
|||
update: updateConfiguration,
|
||||
});
|
||||
|
||||
function updateUIConfig(
|
||||
cache: ApolloCache<Record<string, StoreObject>>,
|
||||
result: GQL.ConfigureUiMutation["configureUI"] | undefined
|
||||
) {
|
||||
if (!result) return;
|
||||
|
||||
const existing = cache.readQuery<GQL.ConfigurationQuery>({
|
||||
query: GQL.ConfigurationDocument,
|
||||
});
|
||||
|
||||
cache.writeQuery({
|
||||
query: GQL.ConfigurationDocument,
|
||||
data: {
|
||||
configuration: {
|
||||
...existing?.configuration,
|
||||
ui: result,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const useConfigureUI = () =>
|
||||
GQL.useConfigureUiMutation({
|
||||
update: updateConfiguration,
|
||||
update: (cache, result) => updateUIConfig(cache, result.data?.configureUI),
|
||||
});
|
||||
|
||||
export const useConfigureUISetting = () =>
|
||||
GQL.useConfigureUiSettingMutation({
|
||||
update: (cache, result) =>
|
||||
updateUIConfig(cache, result.data?.configureUISetting),
|
||||
});
|
||||
|
||||
export const useConfigureScraping = () =>
|
||||
|
|
Loading…
Reference in New Issue