diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 9e390fdd1..706652d3c 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -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" diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index e4a01f830..03df50329 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -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) { diff --git a/pkg/utils/map.go b/pkg/utils/map.go new file mode 100644 index 000000000..ad3c51024 --- /dev/null +++ b/pkg/utils/map.go @@ -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 + } +} diff --git a/pkg/utils/map_test.go b/pkg/utils/map_test.go new file mode 100644 index 000000000..d8e8a2db8 --- /dev/null +++ b/pkg/utils/map_test.go @@ -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) + } + }) + } +} diff --git a/ui/v2.5/graphql/client-schema.graphql b/ui/v2.5/graphql/client-schema.graphql index e16590172..92f53cc3a 100644 --- a/ui/v2.5/graphql/client-schema.graphql +++ b/ui/v2.5/graphql/client-schema.graphql @@ -22,5 +22,5 @@ extend input SetDefaultFilterInput { } extend type Mutation { - configureUI(input: Map!): UIConfig! + configureUI(input: Map, partial: Map): UIConfig! } diff --git a/ui/v2.5/graphql/mutations/config.graphql b/ui/v2.5/graphql/mutations/config.graphql index dfd53ed75..f6a8b47ce 100644 --- a/ui/v2.5/graphql/mutations/config.graphql +++ b/ui/v2.5/graphql/mutations/config.graphql @@ -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!) { diff --git a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx index 5236b3f8a..a0a6d08da 100644 --- a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx @@ -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 = ({ 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) { + 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) { 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 > - + @@ -384,7 +393,7 @@ export const LibraryTasks: React.FC = () => { > setAutoTagOptions(o)} + setOptions={onSetAutoTagOptions} /> @@ -415,7 +424,7 @@ export const LibraryTasks: React.FC = () => { > diff --git a/ui/v2.5/src/components/Settings/context.tsx b/ui/v2.5/src/components/Settings/context.tsx index bb7db4c35..0960836bb 100644 --- a/ui/v2.5/src/components/Settings/context.tsx +++ b/ui/v2.5/src/components/Settings/context.tsx @@ -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) => { 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, diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 669fb8d88..873530d9d 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2301,9 +2301,36 @@ export const useConfigureDefaults = () => update: updateConfiguration, }); +function updateUIConfig( + cache: ApolloCache>, + result: GQL.ConfigureUiMutation["configureUI"] | undefined +) { + if (!result) return; + + const existing = cache.readQuery({ + 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 = () =>