From 9cc4c63d0e94de8bab0c8951e54957468e1973cc Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 22 Sep 2023 14:43:53 +1000 Subject: [PATCH 1/5] Add backend support for plugin settings --- gqlgen.yml | 5 +++ graphql/schema/schema.graphql | 3 ++ graphql/schema/types/config.graphql | 1 + internal/api/resolver.go | 4 ++ internal/api/resolver_model_config.go | 25 +++++++++++ internal/api/resolver_mutation_configure.go | 11 +++++ internal/manager/config/config.go | 48 ++++++++++++++++++++- 7 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 internal/api/resolver_model_config.go diff --git a/gqlgen.yml b/gqlgen.yml index ec9feab24a6..ee96d049dd4 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -150,4 +150,9 @@ models: model: github.com/stashapp/stash/pkg/scraper.Source SavedFindFilterType: model: github.com/stashapp/stash/pkg/models.FindFilterType + # force resolvers + ConfigResult: + fields: + plugins: + resolver: true diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 4c011ad0db2..2b8d823f2f8 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -349,6 +349,9 @@ type Mutation { input: ConfigDefaultSettingsInput! ): ConfigDefaultSettingsResult! + # overwrites the entire plugin configuration for the given plugin + configurePlugin(pluginID: String!, input: Map!): Map! + # overwrites the entire UI configuration configureUI(input: Map!): Map! # sets a single UI key value diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index bbbb63373fe..e42f7ee74e3 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -557,6 +557,7 @@ type ConfigResult { scraping: ConfigScrapingResult! defaults: ConfigDefaultSettingsResult! ui: Map! + plugins(include: [String!]): Map! } "Directory structure of a path" diff --git a/internal/api/resolver.go b/internal/api/resolver.go index ea0bd256c22..020ffd6b026 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -85,6 +85,9 @@ func (r *Resolver) Tag() TagResolver { func (r *Resolver) SavedFilter() SavedFilterResolver { return &savedFilterResolver{r} } +func (r *Resolver) ConfigResult() ConfigResultResolver { + return &configResultResolver{r} +} type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } @@ -100,6 +103,7 @@ type studioResolver struct{ *Resolver } type movieResolver struct{ *Resolver } type tagResolver struct{ *Resolver } type savedFilterResolver struct{ *Resolver } +type configResultResolver struct{ *Resolver } func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error { return txn.WithTxn(ctx, r.txnManager, fn) diff --git a/internal/api/resolver_model_config.go b/internal/api/resolver_model_config.go new file mode 100644 index 00000000000..a255699effb --- /dev/null +++ b/internal/api/resolver_model_config.go @@ -0,0 +1,25 @@ +package api + +import ( + "context" + + "github.com/stashapp/stash/internal/manager/config" +) + +func (r *configResultResolver) Plugins(ctx context.Context, obj *ConfigResult, include []string) (map[string]interface{}, error) { + if len(include) == 0 { + ret := config.GetInstance().GetAllPluginConfiguration() + return ret, nil + } + + ret := make(map[string]interface{}) + + for _, plugin := range include { + c := config.GetInstance().GetPluginConfiguration(plugin) + if len(c) > 0 { + ret[plugin] = c + } + } + + return ret, nil +} diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index f12b3aa0cec..7d7351165d4 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -648,3 +648,14 @@ func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, v return r.ConfigureUI(ctx, cfg) } + +func (r *mutationResolver) ConfigurePlugin(ctx context.Context, pluginID string, input map[string]interface{}) (map[string]interface{}, error) { + c := config.GetInstance() + c.SetPluginConfiguration(pluginID, input) + + if err := c.Write(); err != nil { + return c.GetPluginConfiguration(pluginID), err + } + + return c.GetPluginConfiguration(pluginID), nil +} diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 76d37d2f685..387826c23f6 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -131,7 +131,9 @@ const ( PythonPath = "python_path" // plugin options - PluginsPath = "plugins_path" + PluginsPath = "plugins_path" + PluginsSetting = "plugins.settings" + PluginsSettingPrefix = PluginsSetting + "." // i18n Language = "language" @@ -722,6 +724,50 @@ func (i *Instance) GetPluginsPath() string { return i.getString(PluginsPath) } +func (i *Instance) GetAllPluginConfiguration() map[string]interface{} { + i.RLock() + defer i.RUnlock() + + ret := make(map[string]interface{}) + + sub := i.viper(PluginsSetting).GetStringMap(PluginsSetting) + if sub == nil { + return ret + } + + for plugin := range sub { + // HACK: viper changes map keys to case insensitive values, so the workaround is to + // convert map keys to snake case for storage + ret[plugin] = fromSnakeCaseMap(i.viper(PluginsSetting).GetStringMap(PluginsSettingPrefix + plugin)) + } + + return ret +} + +func (i *Instance) GetPluginConfiguration(pluginID string) map[string]interface{} { + i.RLock() + defer i.RUnlock() + + key := PluginsSettingPrefix + pluginID + + // HACK: viper changes map keys to case insensitive values, so the workaround is to + // convert map keys to snake case for storage + v := i.viper(key).GetStringMap(key) + + return fromSnakeCaseMap(v) +} + +func (i *Instance) SetPluginConfiguration(pluginID string, v map[string]interface{}) { + i.RLock() + defer i.RUnlock() + + key := PluginsSettingPrefix + pluginID + + // HACK: viper changes map keys to case insensitive values, so the workaround is to + // convert map keys to snake case for storage + i.viper(key).Set(key, toSnakeCaseMap(v)) +} + func (i *Instance) GetPythonPath() string { return i.getString(PythonPath) } From 245f8bb568c87211324650abc7d0bff0ffb3c0e1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 22 Sep 2023 15:14:46 +1000 Subject: [PATCH 2/5] Add plugin settings config --- graphql/schema/types/plugin.graphql | 14 ++++++++ pkg/plugin/config.go | 52 +++++++++++++++++++++++++++-- pkg/plugin/plugins.go | 26 ++++++++++----- pkg/plugin/setting.go | 50 +++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 pkg/plugin/setting.go diff --git a/graphql/schema/types/plugin.graphql b/graphql/schema/types/plugin.graphql index b397e0d08b2..8f6a9696f58 100644 --- a/graphql/schema/types/plugin.graphql +++ b/graphql/schema/types/plugin.graphql @@ -7,6 +7,7 @@ type Plugin { tasks: [PluginTask!] hooks: [PluginHook!] + settings: [PluginSetting!] } type PluginTask { @@ -40,3 +41,16 @@ input PluginValueInput { o: [PluginArgInput!] a: [PluginValueInput!] } + +enum PluginSettingTypeEnum { + STRING + NUMBER + BOOLEAN +} + +type PluginSetting { + name: String! + display_name: String + description: String + type: PluginSettingTypeEnum! +} diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go index eac7289a805..89f7b0044ca 100644 --- a/pkg/plugin/config.go +++ b/pkg/plugin/config.go @@ -59,6 +59,9 @@ type Config struct { // Javascript files that will be injected into the stash UI. UI UIConfig `yaml:"ui"` + + // Settings that will be used to configure the plugin. + Settings map[string]SettingConfig `yaml:"settings"` } type UIConfig struct { @@ -87,6 +90,14 @@ func (c UIConfig) getJavascriptFiles(parent Config) []string { return ret } +type SettingConfig struct { + // defaults to string + Type PluginSettingTypeEnum `yaml:"type"` + // defaults to key name + DisplayName string `yaml:"displayName"` + Description string `yaml:"description"` +} + func (c Config) getPluginTasks(includePlugin bool) []*PluginTask { var ret []*PluginTask @@ -133,6 +144,28 @@ func convertHooks(hooks []HookTriggerEnum) []string { return ret } +func (c Config) getPluginSettings() []PluginSetting { + ret := []PluginSetting{} + + for k, o := range c.Settings { + t := o.Type + if t == "" { + t = PluginSettingTypeEnumString + } + + s := PluginSetting{ + Name: k, + DisplayName: o.DisplayName, + Description: o.Description, + Type: o.Type, + } + + ret = append(ret, s) + } + + return ret +} + func (c Config) getName() string { if c.Name != "" { return c.Name @@ -154,6 +187,7 @@ func (c Config) toPlugin() *Plugin { Javascript: c.UI.getJavascriptFiles(c), CSS: c.UI.getCSSFiles(c), }, + Settings: c.getPluginSettings(), } } @@ -211,6 +245,20 @@ func (c Config) getExecCommand(task *OperationConfig) []string { return ret } +func (c Config) valid() error { + if c.Interface != "" && !c.Interface.Valid() { + return fmt.Errorf("invalid interface type %s", c.Interface) + } + + for k, o := range c.Settings { + if o.Type != "" && !o.Type.IsValid() { + return fmt.Errorf("invalid type %s for setting %s", k, o.Type) + } + } + + return nil +} + type interfaceEnum string // Valid interfaceEnum values @@ -292,8 +340,8 @@ func loadPluginFromYAML(reader io.Reader) (*Config, error) { ret.Interface = InterfaceEnumRaw } - if !ret.Interface.Valid() { - return nil, fmt.Errorf("invalid interface type %s", ret.Interface) + if err := ret.valid(); err != nil { + return nil, err } return ret, nil diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index 8e9354d0b97..0287e89650f 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -24,14 +24,15 @@ import ( ) type Plugin struct { - ID string `json:"id"` - Name string `json:"name"` - Description *string `json:"description"` - URL *string `json:"url"` - Version *string `json:"version"` - Tasks []*PluginTask `json:"tasks"` - Hooks []*PluginHook `json:"hooks"` - UI PluginUI `json:"ui"` + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + URL *string `json:"url"` + Version *string `json:"version"` + Tasks []*PluginTask `json:"tasks"` + Hooks []*PluginHook `json:"hooks"` + UI PluginUI `json:"ui"` + Settings []PluginSetting `json:"settings"` } type PluginUI struct { @@ -42,6 +43,15 @@ type PluginUI struct { CSS []string `json:"css"` } +type PluginSetting struct { + Name string `json:"name"` + // defaults to string + Type PluginSettingTypeEnum `json:"type"` + // defaults to key name + DisplayName string `json:"displayName"` + Description string `json:"description"` +} + type ServerConfig interface { GetHost() string GetPort() int diff --git a/pkg/plugin/setting.go b/pkg/plugin/setting.go new file mode 100644 index 00000000000..582a4fe1d52 --- /dev/null +++ b/pkg/plugin/setting.go @@ -0,0 +1,50 @@ +package plugin + +import ( + "fmt" + "io" + "strconv" +) + +type PluginSettingTypeEnum string + +const ( + PluginSettingTypeEnumString PluginSettingTypeEnum = "STRING" + PluginSettingTypeEnumNumber PluginSettingTypeEnum = "NUMBER" + PluginSettingTypeEnumBoolean PluginSettingTypeEnum = "BOOLEAN" +) + +var AllPluginSettingTypeEnum = []PluginSettingTypeEnum{ + PluginSettingTypeEnumString, + PluginSettingTypeEnumNumber, + PluginSettingTypeEnumBoolean, +} + +func (e PluginSettingTypeEnum) IsValid() bool { + switch e { + case PluginSettingTypeEnumString, PluginSettingTypeEnumNumber, PluginSettingTypeEnumBoolean: + return true + } + return false +} + +func (e PluginSettingTypeEnum) String() string { + return string(e) +} + +func (e *PluginSettingTypeEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = PluginSettingTypeEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid PluginSettingTypeEnum", str) + } + return nil +} + +func (e PluginSettingTypeEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} From 2489696110863644ad0c0b3976a0a3fcb1776475 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 22 Sep 2023 16:45:36 +1000 Subject: [PATCH 3/5] Fix viper issues --- graphql/schema/schema.graphql | 2 +- internal/manager/config/config.go | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 2b8d823f2f8..d693b38f66e 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -350,7 +350,7 @@ type Mutation { ): ConfigDefaultSettingsResult! # overwrites the entire plugin configuration for the given plugin - configurePlugin(pluginID: String!, input: Map!): Map! + configurePlugin(plugin_id: ID!, input: Map!): Map! # overwrites the entire UI configuration configureUI(input: Map!): Map! diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 387826c23f6..ad073d9c6ef 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -738,7 +738,8 @@ func (i *Instance) GetAllPluginConfiguration() map[string]interface{} { for plugin := range sub { // HACK: viper changes map keys to case insensitive values, so the workaround is to // convert map keys to snake case for storage - ret[plugin] = fromSnakeCaseMap(i.viper(PluginsSetting).GetStringMap(PluginsSettingPrefix + plugin)) + name := fromSnakeCase(plugin) + ret[name] = fromSnakeCaseMap(i.viper(PluginsSetting).GetStringMap(PluginsSettingPrefix + plugin)) } return ret @@ -748,7 +749,7 @@ func (i *Instance) GetPluginConfiguration(pluginID string) map[string]interface{ i.RLock() defer i.RUnlock() - key := PluginsSettingPrefix + pluginID + key := PluginsSettingPrefix + toSnakeCase(pluginID) // HACK: viper changes map keys to case insensitive values, so the workaround is to // convert map keys to snake case for storage @@ -761,6 +762,8 @@ func (i *Instance) SetPluginConfiguration(pluginID string, v map[string]interfac i.RLock() defer i.RUnlock() + pluginID = toSnakeCase(pluginID) + key := PluginsSettingPrefix + pluginID // HACK: viper changes map keys to case insensitive values, so the workaround is to From 4ca374586bec9a0fca817b74f1d38df19cb51c28 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 22 Sep 2023 16:45:50 +1000 Subject: [PATCH 4/5] Add UI support for plugin settings --- graphql/documents/data/config.graphql | 1 + graphql/documents/mutations/plugins.graphql | 4 + graphql/documents/queries/plugins.graphql | 7 ++ ui/v2.5/src/components/Settings/Inputs.tsx | 12 ++- .../SettingsInterfacePanel.tsx | 4 +- .../Settings/SettingsLibraryPanel.tsx | 4 +- .../Settings/SettingsPluginsPanel.tsx | 84 ++++++++++++++- .../Settings/SettingsScrapingPanel.tsx | 4 +- .../Settings/SettingsSecurityPanel.tsx | 4 +- .../Settings/SettingsServicesPanel.tsx | 9 +- .../Settings/SettingsSystemPanel.tsx | 5 +- ui/v2.5/src/components/Settings/context.tsx | 101 ++++++++++++++---- ui/v2.5/src/core/StashService.ts | 5 + 13 files changed, 202 insertions(+), 42 deletions(-) diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 019f56a6e1c..171b5aedca0 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -212,4 +212,5 @@ fragment ConfigData on ConfigResult { ...ConfigDefaultSettingsData } ui + plugins } diff --git a/graphql/documents/mutations/plugins.graphql b/graphql/documents/mutations/plugins.graphql index d964bd6b2a0..f53eb819219 100644 --- a/graphql/documents/mutations/plugins.graphql +++ b/graphql/documents/mutations/plugins.graphql @@ -9,3 +9,7 @@ mutation RunPluginTask( ) { runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args: $args) } + +mutation ConfigurePlugin($plugin_id: ID!, $input: Map!) { + configurePlugin(plugin_id: $plugin_id, input: $input) +} diff --git a/graphql/documents/queries/plugins.graphql b/graphql/documents/queries/plugins.graphql index d827542b611..c642df47807 100644 --- a/graphql/documents/queries/plugins.graphql +++ b/graphql/documents/queries/plugins.graphql @@ -16,6 +16,13 @@ query Plugins { description hooks } + + settings { + name + display_name + description + type + } } } diff --git a/ui/v2.5/src/components/Settings/Inputs.tsx b/ui/v2.5/src/components/Settings/Inputs.tsx index 9ecf1d54c3d..d05afbfad5d 100644 --- a/ui/v2.5/src/components/Settings/Inputs.tsx +++ b/ui/v2.5/src/components/Settings/Inputs.tsx @@ -192,6 +192,7 @@ export const ChangeButtonSetting = (props: IDialogSetting) => { id, className, headingID, + heading, tooltipID, subHeadingID, subHeading, @@ -211,7 +212,11 @@ export const ChangeButtonSetting = (props: IDialogSetting) => {

- {headingID ? intl.formatMessage({ id: headingID }) : undefined} + {headingID + ? intl.formatMessage({ id: headingID }) + : heading + ? heading + : undefined}

@@ -240,7 +245,7 @@ export const ChangeButtonSetting = (props: IDialogSetting) => { }; export interface ISettingModal { - heading?: string; + heading?: React.ReactNode; headingID?: string; subHeadingID?: string; subHeading?: React.ReactNode; @@ -319,6 +324,7 @@ export const ModalSetting = (props: IModalSetting) => { className, value, headingID, + heading, subHeadingID, subHeading, onChange, @@ -338,6 +344,7 @@ export const ModalSetting = (props: IModalSetting) => { headingID={headingID} subHeadingID={subHeadingID} + heading={heading} subHeading={subHeading} value={value} renderField={renderField} @@ -356,6 +363,7 @@ export const ModalSetting = (props: IModalSetting) => { buttonText={buttonText} buttonTextID={buttonTextID} headingID={headingID} + heading={heading} tooltipID={tooltipID} subHeadingID={subHeadingID} subHeading={subHeading} diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 49f72b52db1..c3c36417e04 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -13,7 +13,7 @@ import { SelectSetting, StringSetting, } from "../Inputs"; -import { SettingStateContext } from "../context"; +import { useSettings } from "../context"; import DurationUtils from "src/utils/duration"; import * as GQL from "src/core/generated-graphql"; import { @@ -65,7 +65,7 @@ export const SettingsInterfacePanel: React.FC = () => { saveUI, loading, error, - } = React.useContext(SettingStateContext); + } = useSettings(); const { interactive, diff --git a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx index d8cc0f67cee..76c72968f95 100644 --- a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx @@ -4,14 +4,14 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { StashSetting } from "./StashConfiguration"; import { SettingSection } from "./SettingSection"; import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs"; -import { SettingStateContext } from "./context"; +import { useSettings } from "./context"; import { useIntl } from "react-intl"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; export const SettingsLibraryPanel: React.FC = () => { const intl = useIntl(); const { general, loading, error, saveGeneral, defaults, saveDefaults } = - React.useContext(SettingStateContext); + useSettings(); function commaDelimitedToList(value: string | undefined) { if (value) { diff --git a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx index 0cd02703c10..112e8ce35b5 100644 --- a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx @@ -9,13 +9,68 @@ import { CollapseButton } from "../Shared/CollapseButton"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { SettingSection } from "./SettingSection"; -import { Setting, SettingGroup } from "./Inputs"; +import { + BooleanSetting, + NumberSetting, + Setting, + SettingGroup, + StringSetting, +} from "./Inputs"; import { faLink, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; +import { useSettings } from "./context"; + +interface IPluginSettingProps { + pluginID: string; + setting: GQL.PluginSetting; + value: unknown; + onChange: (value: unknown) => void; +} + +const PluginSetting: React.FC = ({ + pluginID, + setting, + value, + onChange, +}) => { + const commonProps = { + heading: setting.display_name ? setting.display_name : setting.name, + id: `plugin-${pluginID}-${setting.name}`, + subHeading: setting.description ?? undefined, + }; + + switch (setting.type) { + case GQL.PluginSettingTypeEnum.Boolean: + return ( + onChange(!value)} + /> + ); + case GQL.PluginSettingTypeEnum.String: + return ( + onChange(v)} + /> + ); + case GQL.PluginSettingTypeEnum.Number: + return ( + onChange(v)} + /> + ); + } +}; export const SettingsPluginsPanel: React.FC = () => { const Toast = useToast(); const intl = useIntl(); + const { loading: configLoading, plugins, savePluginSettings } = useSettings(); const { data, loading } = usePlugins(); async function onReloadPlugins() { @@ -53,6 +108,7 @@ export const SettingsPluginsPanel: React.FC = () => { topLevel={renderLink(plugin.url ?? undefined)} > {renderPluginHooks(plugin.hooks ?? undefined)} + {renderPluginSettings(plugin.id, plugin.settings ?? [])} )); @@ -97,10 +153,32 @@ export const SettingsPluginsPanel: React.FC = () => { ); } + function renderPluginSettings( + pluginID: string, + settings: GQL.PluginSetting[] + ) { + const pluginSettings = plugins[pluginID] ?? {}; + + return settings.map((setting) => ( + + savePluginSettings(pluginID, { + ...pluginSettings, + [setting.name]: v, + }) + } + /> + )); + } + return renderPlugins(); - }, [data?.plugins, intl]); + }, [data?.plugins, intl, plugins, savePluginSettings]); - if (loading) return ; + if (loading || configLoading) return ; return ( <> diff --git a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx index d0453fbb404..4f090f9cae1 100644 --- a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx @@ -16,7 +16,7 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ScrapeType } from "src/core/generated-graphql"; import { SettingSection } from "./SettingSection"; import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs"; -import { SettingStateContext } from "./context"; +import { useSettings } from "./context"; import { StashBoxSetting } from "./StashBoxConfiguration"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; @@ -87,7 +87,7 @@ export const SettingsScrapingPanel: React.FC = () => { useListMovieScrapers(); const { general, scraping, loading, error, saveGeneral, saveScraping } = - React.useContext(SettingStateContext); + useSettings(); async function onReloadScrapers() { await mutateReloadScrapers().catch((e) => Toast.error(e)); diff --git a/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx index b9921793873..aaed1e7d4ef 100644 --- a/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx @@ -4,7 +4,7 @@ import { SettingSection } from "./SettingSection"; import * as GQL from "src/core/generated-graphql"; import { Button, Form } from "react-bootstrap"; import { useIntl } from "react-intl"; -import { SettingStateContext } from "./context"; +import { useSettings } from "./context"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { useGenerateAPIKey } from "src/core/StashService"; @@ -72,7 +72,7 @@ export const SettingsSecurityPanel: React.FC = () => { const Toast = useToast(); const { general, apiKey, loading, error, saveGeneral, refetch } = - React.useContext(SettingStateContext); + useSettings(); const [generateAPIKey] = useGenerateAPIKey(); diff --git a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx index 57a8bf99fc0..d25b5452b20 100644 --- a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx @@ -20,7 +20,7 @@ import { StringSetting, SelectSetting, } from "./Inputs"; -import { SettingStateContext } from "./context"; +import { useSettings } from "./context"; import { videoSortOrderIntlMap, defaultVideoSort, @@ -35,12 +35,7 @@ export const SettingsServicesPanel: React.FC = () => { const intl = useIntl(); const Toast = useToast(); - const { - dlna, - loading: configLoading, - error, - saveDLNA, - } = React.useContext(SettingStateContext); + const { dlna, loading: configLoading, error, saveDLNA } = useSettings(); // undefined to hide dialog, true for enable, false for disable const [enableDisable, setEnableDisable] = useState(); diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index c5b0f36c1c3..d41a4a3bc64 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -10,7 +10,7 @@ import { StringListSetting, StringSetting, } from "./Inputs"; -import { SettingStateContext } from "./context"; +import { useSettings } from "./context"; import { VideoPreviewInput, VideoPreviewSettingsInput, @@ -20,8 +20,7 @@ import { useIntl } from "react-intl"; export const SettingsConfigurationPanel: React.FC = () => { const intl = useIntl(); - const { general, loading, error, saveGeneral } = - React.useContext(SettingStateContext); + const { general, loading, error, saveGeneral } = useSettings(); const transcodeQualities = [ GQL.StreamingResolutionEnum.Low, diff --git a/ui/v2.5/src/components/Settings/context.tsx b/ui/v2.5/src/components/Settings/context.tsx index eae1bd4040f..7f4e4702faf 100644 --- a/ui/v2.5/src/components/Settings/context.tsx +++ b/ui/v2.5/src/components/Settings/context.tsx @@ -13,6 +13,7 @@ import { useConfigureDLNA, useConfigureGeneral, useConfigureInterface, + useConfigurePlugin, useConfigureScraping, useConfigureUI, } from "src/core/StashService"; @@ -21,6 +22,7 @@ import { useToast } from "src/hooks/Toast"; import { withoutTypename } from "src/utils/data"; import { Icon } from "../Shared/Icon"; +type PluginSettings = Record>; export interface ISettingsContextState { loading: boolean; error: ApolloError | undefined; @@ -30,6 +32,7 @@ export interface ISettingsContextState { scraping: GQL.ConfigScrapingInput; dlna: GQL.ConfigDlnaInput; ui: IUIConfig; + plugins: PluginSettings; // apikey isn't directly settable, so expose it here apiKey: string; @@ -40,28 +43,23 @@ export interface ISettingsContextState { saveScraping: (input: Partial) => void; saveDLNA: (input: Partial) => void; saveUI: (input: Partial) => void; + savePluginSettings: (pluginID: string, input: {}) => void; refetch: () => void; } -export const SettingStateContext = React.createContext({ - loading: false, - error: undefined, - general: {}, - interface: {}, - defaults: {}, - scraping: {}, - dlna: {}, - ui: {}, - apiKey: "", - saveGeneral: () => {}, - saveInterface: () => {}, - saveDefaults: () => {}, - saveScraping: () => {}, - saveDLNA: () => {}, - saveUI: () => {}, - refetch: () => {}, -}); +export const SettingStateContext = + React.createContext(null); + +export const useSettings = () => { + const context = React.useContext(SettingStateContext); + + if (context === null) { + throw new Error("useSettings must be used within a SettingsContext"); + } + + return context; +}; export const SettingsContext: React.FC = ({ children }) => { const Toast = useToast(); @@ -97,6 +95,10 @@ export const SettingsContext: React.FC = ({ children }) => { const [pendingUI, setPendingUI] = useState<{}>(); const [updateUIConfig] = useConfigureUI(); + const [plugins, setPlugins] = useState({}); + const [pendingPlugins, setPendingPlugins] = useState(); + const [updatePluginConfig] = useConfigurePlugin(); + const [updateSuccess, setUpdateSuccess] = useState(); const [apiKey, setApiKey] = useState(""); @@ -132,6 +134,7 @@ export const SettingsContext: React.FC = ({ children }) => { setScraping({ ...withoutTypename(data.configuration.scraping) }); setDLNA({ ...withoutTypename(data.configuration.dlna) }); setUI(data.configuration.ui); + setPlugins(data.configuration.plugins); }, [data, error]); const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), 4000); @@ -433,6 +436,63 @@ export const SettingsContext: React.FC = ({ children }) => { }); } + // saves the configuration if no further changes are made after a half second + const savePluginConfig = useDebounce(async (input: PluginSettings) => { + try { + setUpdateSuccess(undefined); + + for (const pluginID in input) { + await updatePluginConfig({ + variables: { + plugin_id: pluginID, + input: input[pluginID], + }, + }); + } + + setPendingPlugins(undefined); + onSuccess(); + } catch (e) { + setSaveError(e); + } + }, 500); + + useEffect(() => { + if (!pendingPlugins) { + return; + } + + savePluginConfig(pendingPlugins); + }, [pendingPlugins, savePluginConfig]); + + function savePluginSettings( + pluginID: string, + input: Record + ) { + if (!plugins) { + return; + } + + setPlugins({ + ...plugins, + [pluginID]: input, + }); + + setPendingPlugins((current) => { + if (!current) { + // use full UI object to ensure nothing is wiped + return { + ...plugins, + [pluginID]: input, + }; + } + return { + ...current, + [pluginID]: input, + }; + }); + } + function maybeRenderLoadingIndicator() { if (updateSuccess === false) { return ( @@ -448,7 +508,8 @@ export const SettingsContext: React.FC = ({ children }) => { pendingDefaults || pendingScraping || pendingDLNA || - pendingUI + pendingUI || + pendingPlugins ) { return (
@@ -480,6 +541,7 @@ export const SettingsContext: React.FC = ({ children }) => { scraping, dlna, ui, + plugins, saveGeneral, saveInterface, saveDefaults, @@ -487,6 +549,7 @@ export const SettingsContext: React.FC = ({ children }) => { saveDLNA, saveUI, refetch, + savePluginSettings, }} > {maybeRenderLoadingIndicator()} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index c7270e2e23d..932a76d2d3b 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2034,6 +2034,11 @@ export const useConfigureDLNA = () => update: updateConfiguration, }); +export const useConfigurePlugin = () => + GQL.useConfigurePluginMutation({ + update: updateConfiguration, + }); + export const useEnableDLNA = () => GQL.useEnableDlnaMutation(); export const useDisableDLNA = () => GQL.useDisableDlnaMutation(); From 637d66583d6a8f8ba8f5ee130a764a3062c59e2f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:39:24 +1100 Subject: [PATCH 5/5] Lint --- pkg/plugin/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go index 89f7b0044ca..6529402b24c 100644 --- a/pkg/plugin/config.go +++ b/pkg/plugin/config.go @@ -157,7 +157,7 @@ func (c Config) getPluginSettings() []PluginSetting { Name: k, DisplayName: o.DisplayName, Description: o.Description, - Type: o.Type, + Type: t, } ret = append(ret, s)