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:
WithoutPants 2024-03-14 08:25:16 +11:00 committed by GitHub
parent bf7cb78d6d
commit 7ac7963972
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 383 additions and 54 deletions

View File

@ -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"

View File

@ -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) {

64
pkg/utils/map.go Normal file
View File

@ -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
}
}

218
pkg/utils/map_test.go Normal file
View File

@ -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)
}
})
}
}

View File

@ -22,5 +22,5 @@ extend input SetDefaultFilterInput {
}
extend type Mutation {
configureUI(input: Map!): UIConfig!
configureUI(input: Map, partial: Map): UIConfig!
}

View File

@ -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!) {

View File

@ -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>

View File

@ -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,

View File

@ -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 = () =>