diff --git a/gqlgen.yml b/gqlgen.yml index ec9feab24a6..5e8f09444b6 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -33,6 +33,8 @@ models: model: github.com/99designs/gqlgen/graphql.Int64 Timestamp: model: github.com/stashapp/stash/internal/api.Timestamp + BoolMap: + model: github.com/stashapp/stash/internal/api.BoolMap # define to force resolvers Image: model: github.com/stashapp/stash/pkg/models.Image diff --git a/graphql/documents/mutations/plugins.graphql b/graphql/documents/mutations/plugins.graphql index d964bd6b2a0..0ce26740403 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 SetPluginsEnabled($enabledMap: BoolMap!) { + setPluginsEnabled(enabledMap: $enabledMap) +} diff --git a/graphql/documents/queries/plugins.graphql b/graphql/documents/queries/plugins.graphql index d827542b611..0a757cac321 100644 --- a/graphql/documents/queries/plugins.graphql +++ b/graphql/documents/queries/plugins.graphql @@ -2,6 +2,7 @@ query Plugins { plugins { id name + enabled description url version @@ -26,6 +27,7 @@ query PluginTasks { plugin { id name + enabled } } } diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 4c011ad0db2..52a6c168824 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -394,6 +394,12 @@ type Mutation { "Reload scrapers" reloadScrapers: Boolean! + """ + Enable/disable plugins - enabledMap is a map of plugin IDs to enabled booleans. + Plugins not in the map are not affected. + """ + setPluginsEnabled(enabledMap: BoolMap!): Boolean! + "Run plugin task. Returns the job ID" runPluginTask( plugin_id: ID! diff --git a/graphql/schema/types/plugin.graphql b/graphql/schema/types/plugin.graphql index b397e0d08b2..e9da728b53e 100644 --- a/graphql/schema/types/plugin.graphql +++ b/graphql/schema/types/plugin.graphql @@ -5,6 +5,8 @@ type Plugin { url: String version: String + enabled: Boolean! + tasks: [PluginTask!] hooks: [PluginHook!] } diff --git a/graphql/schema/types/scalars.graphql b/graphql/schema/types/scalars.graphql index 2e4c592913f..8f1d21551bf 100644 --- a/graphql/schema/types/scalars.graphql +++ b/graphql/schema/types/scalars.graphql @@ -8,6 +8,9 @@ scalar Timestamp # generic JSON object scalar Map +# string, boolean map +scalar BoolMap + scalar Any scalar Int64 diff --git a/internal/api/bool_map.go b/internal/api/bool_map.go new file mode 100644 index 00000000000..32c0fcacb46 --- /dev/null +++ b/internal/api/bool_map.go @@ -0,0 +1,38 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/99designs/gqlgen/graphql" +) + +func MarshalBoolMap(val map[string]bool) graphql.Marshaler { + return graphql.WriterFunc(func(w io.Writer) { + err := json.NewEncoder(w).Encode(val) + if err != nil { + panic(err) + } + }) +} + +func UnmarshalBoolMap(v interface{}) (map[string]bool, error) { + m, ok := v.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%T is not a map", v) + } + + result := make(map[string]bool) + for k, v := range m { + key := k + val, ok := v.(bool) + if !ok { + return nil, fmt.Errorf("key %s (%T) is not a bool", k, v) + } + + result[key] = val + } + + return result, nil +} diff --git a/internal/api/models.go b/internal/api/models.go index 03c20ee4396..40d93cc2e41 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -1,16 +1,7 @@ package api import ( - "errors" - "fmt" - "io" - "strconv" - "time" - - "github.com/99designs/gqlgen/graphql" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/utils" ) type BaseFile interface{} @@ -18,47 +9,3 @@ type BaseFile interface{} type GalleryFile struct { *models.BaseFile } - -var ErrTimestamp = errors.New("cannot parse Timestamp") - -func MarshalTimestamp(t time.Time) graphql.Marshaler { - if t.IsZero() { - return graphql.Null - } - - return graphql.WriterFunc(func(w io.Writer) { - _, err := io.WriteString(w, strconv.Quote(t.Format(time.RFC3339Nano))) - if err != nil { - logger.Warnf("could not marshal timestamp: %v", err) - } - }) -} - -func UnmarshalTimestamp(v interface{}) (time.Time, error) { - if tmpStr, ok := v.(string); ok { - if len(tmpStr) == 0 { - return time.Time{}, fmt.Errorf("%w: empty string", ErrTimestamp) - } - - switch tmpStr[0] { - case '>', '<': - d, err := time.ParseDuration(tmpStr[1:]) - if err != nil { - return time.Time{}, fmt.Errorf("%w: cannot parse %v-duration: %v", ErrTimestamp, tmpStr[0], err) - } - t := time.Now() - // Compute point in time: - if tmpStr[0] == '<' { - t = t.Add(-d) - } else { - t = t.Add(d) - } - - return t, nil - } - - return utils.ParseDateStringAsTime(tmpStr) - } - - return time.Time{}, fmt.Errorf("%w: not a string", ErrTimestamp) -} diff --git a/internal/api/resolver_mutation_plugin.go b/internal/api/resolver_mutation_plugin.go index 58ad359b71a..8b3d554f892 100644 --- a/internal/api/resolver_mutation_plugin.go +++ b/internal/api/resolver_mutation_plugin.go @@ -4,8 +4,10 @@ import ( "context" "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *mutationResolver) RunPluginTask(ctx context.Context, pluginID string, taskName string, args []*plugin.PluginArgInput) (string, error) { @@ -22,3 +24,32 @@ func (r *mutationResolver) ReloadPlugins(ctx context.Context) (bool, error) { return true, nil } + +func (r *mutationResolver) SetPluginsEnabled(ctx context.Context, enabledMap map[string]bool) (bool, error) { + c := config.GetInstance() + + existingDisabled := c.GetDisabledPlugins() + var newDisabled []string + + // remove plugins that are no longer disabled + for _, disabledID := range existingDisabled { + if enabled, found := enabledMap[disabledID]; !enabled || !found { + newDisabled = append(newDisabled, disabledID) + } + } + + // add plugins that are newly disabled + for pluginID, enabled := range enabledMap { + if !enabled { + newDisabled = stringslice.StrAppendUnique(newDisabled, pluginID) + } + } + + c.Set(config.DisabledPlugins, newDisabled) + + if err := c.Write(); err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/api/server.go b/internal/api/server.go index 6c8ea857114..67a45666c95 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -295,6 +295,10 @@ func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.Respo var paths []string for _, p := range pluginCache.ListPlugins() { + if !p.Enabled { + continue + } + paths = append(paths, p.UI.CSS...) } @@ -318,6 +322,10 @@ func javascriptHandler(c *config.Instance, pluginCache *plugin.Cache) func(w htt var paths []string for _, p := range pluginCache.ListPlugins() { + if !p.Enabled { + continue + } + paths = append(paths, p.UI.Javascript...) } diff --git a/internal/api/timestamp.go b/internal/api/timestamp.go new file mode 100644 index 00000000000..92713a56e8c --- /dev/null +++ b/internal/api/timestamp.go @@ -0,0 +1,57 @@ +package api + +import ( + "errors" + "fmt" + "io" + "strconv" + "time" + + "github.com/99designs/gqlgen/graphql" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/utils" +) + +var ErrTimestamp = errors.New("cannot parse Timestamp") + +func MarshalTimestamp(t time.Time) graphql.Marshaler { + if t.IsZero() { + return graphql.Null + } + + return graphql.WriterFunc(func(w io.Writer) { + _, err := io.WriteString(w, strconv.Quote(t.Format(time.RFC3339Nano))) + if err != nil { + logger.Warnf("could not marshal timestamp: %v", err) + } + }) +} + +func UnmarshalTimestamp(v interface{}) (time.Time, error) { + if tmpStr, ok := v.(string); ok { + if len(tmpStr) == 0 { + return time.Time{}, fmt.Errorf("%w: empty string", ErrTimestamp) + } + + switch tmpStr[0] { + case '>', '<': + d, err := time.ParseDuration(tmpStr[1:]) + if err != nil { + return time.Time{}, fmt.Errorf("%w: cannot parse %v-duration: %v", ErrTimestamp, tmpStr[0], err) + } + t := time.Now() + // Compute point in time: + if tmpStr[0] == '<' { + t = t.Add(-d) + } else { + t = t.Add(d) + } + + return t, nil + } + + return utils.ParseDateStringAsTime(tmpStr) + } + + return time.Time{}, fmt.Errorf("%w: not a string", ErrTimestamp) +} diff --git a/internal/api/models_test.go b/internal/api/timestamp_test.go similarity index 100% rename from internal/api/models_test.go rename to internal/api/timestamp_test.go diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 113d3668524..0676a7a63f4 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -131,7 +131,8 @@ const ( PythonPath = "python_path" // plugin options - PluginsPath = "plugins_path" + PluginsPath = "plugins_path" + DisabledPlugins = "plugins.disabled" // i18n Language = "language" @@ -722,6 +723,10 @@ func (i *Instance) GetPluginsPath() string { return i.getString(PluginsPath) } +func (i *Instance) GetDisabledPlugins() []string { + return i.getStringSlice(DisabledPlugins) +} + func (i *Instance) GetPythonPath() string { return i.getString(PythonPath) } diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index 8e9354d0b97..addc22d6714 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -32,6 +32,8 @@ type Plugin struct { Tasks []*PluginTask `json:"tasks"` Hooks []*PluginHook `json:"hooks"` UI PluginUI `json:"ui"` + + Enabled bool `json:"enabled"` } type PluginUI struct { @@ -48,6 +50,7 @@ type ServerConfig interface { GetConfigPath() string HasTLSConfig() bool GetPluginsPath() string + GetDisabledPlugins() []string GetPythonPath() string } @@ -122,11 +125,39 @@ func loadPlugins(path string) ([]Config, error) { return plugins, nil } +func (c Cache) enabledPlugins() []Config { + disabledPlugins := c.config.GetDisabledPlugins() + + var ret []Config + for _, p := range c.plugins { + disabled := stringslice.StrInclude(disabledPlugins, p.id) + + if !disabled { + ret = append(ret, p) + } + } + + return ret +} + +func (c Cache) pluginDisabled(id string) bool { + disabledPlugins := c.config.GetDisabledPlugins() + + return stringslice.StrInclude(disabledPlugins, id) +} + // ListPlugins returns plugin details for all of the loaded plugins. func (c Cache) ListPlugins() []*Plugin { + disabledPlugins := c.config.GetDisabledPlugins() + var ret []*Plugin for _, s := range c.plugins { - ret = append(ret, s.toPlugin()) + p := s.toPlugin() + + disabled := stringslice.StrInclude(disabledPlugins, p.ID) + p.Enabled = !disabled + + ret = append(ret, p) } return ret @@ -135,7 +166,7 @@ func (c Cache) ListPlugins() []*Plugin { // ListPluginTasks returns all runnable plugin tasks in all loaded plugins. func (c Cache) ListPluginTasks() []*PluginTask { var ret []*PluginTask - for _, s := range c.plugins { + for _, s := range c.enabledPlugins() { ret = append(ret, s.getPluginTasks(true)...) } @@ -175,6 +206,10 @@ func (c Cache) makeServerConnection(ctx context.Context) common.StashServerConne func (c Cache) CreateTask(ctx context.Context, pluginID string, operationName string, args []*PluginArgInput, progress chan float64) (Task, error) { serverConnection := c.makeServerConnection(ctx) + if c.pluginDisabled(pluginID) { + return nil, fmt.Errorf("plugin %s is disabled", pluginID) + } + // find the plugin and operation plugin := c.getPlugin(pluginID) @@ -227,7 +262,7 @@ func (c Cache) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.Sce func (c Cache) executePostHooks(ctx context.Context, hookType HookTriggerEnum, hookContext common.HookContext) error { visitedPlugins := session.GetVisitedPlugins(ctx) - for _, p := range c.plugins { + for _, p := range c.enabledPlugins() { hooks := p.getHooks(hookType) // don't revisit a plugin we've already visited // only log if there's hooks that we're skipping diff --git a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx index 0cd02703c10..0fc0bbbdaed 100644 --- a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx @@ -2,7 +2,11 @@ import React, { useMemo } from "react"; import { Button } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; -import { mutateReloadPlugins, usePlugins } from "src/core/StashService"; +import { + mutateReloadPlugins, + mutateSetPluginsEnabled, + usePlugins, +} from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; import { CollapseButton } from "../Shared/CollapseButton"; @@ -16,7 +20,11 @@ export const SettingsPluginsPanel: React.FC = () => { const Toast = useToast(); const intl = useIntl(); - const { data, loading } = usePlugins(); + const [changedPluginID, setChangedPluginID] = React.useState< + string | undefined + >(); + + const { data, loading, refetch } = usePlugins(); async function onReloadPlugins() { await mutateReloadPlugins().catch((e) => Toast.error(e)); @@ -40,6 +48,39 @@ export const SettingsPluginsPanel: React.FC = () => { } } + function renderEnableButton(pluginID: string, enabled: boolean) { + async function onClick() { + await mutateSetPluginsEnabled({ [pluginID]: !enabled }).catch((e) => + Toast.error(e) + ); + + setChangedPluginID(pluginID); + refetch(); + } + + return ( + + ); + } + + function onReloadUI() { + window.location.reload(); + } + + function maybeRenderReloadUI(pluginID: string) { + if (pluginID === changedPluginID) { + return ( + + ); + } + } + function renderPlugins() { const elements = (data?.plugins ?? []).map((plugin) => ( { heading: `${plugin.name} ${ plugin.version ? `(${plugin.version})` : undefined }`, + className: !plugin.enabled ? "disabled" : undefined, subHeading: plugin.description, }} - topLevel={renderLink(plugin.url ?? undefined)} + topLevel={ + <> + {renderLink(plugin.url ?? undefined)} + {maybeRenderReloadUI(plugin.id)} + {renderEnableButton(plugin.id, plugin.enabled)} + + } > {renderPluginHooks(plugin.hooks ?? undefined)} @@ -98,7 +146,7 @@ export const SettingsPluginsPanel: React.FC = () => { } return renderPlugins(); - }, [data?.plugins, intl]); + }, [data?.plugins, intl, Toast, changedPluginID, refetch]); if (loading) return ; diff --git a/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx index 396e1d66af0..8a6110ab8da 100644 --- a/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx @@ -22,7 +22,7 @@ export const PluginTasks: React.FC = () => { } const taskPlugins = plugins.data.plugins.filter( - (p) => p.tasks && p.tasks.length > 0 + (p) => p.enabled && p.tasks && p.tasks.length > 0 ); return ( diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index f6313b2eef3..9a7f516adf3 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2088,6 +2088,14 @@ export const mutateMigrate = (input: GQL.MigrateInput) => }, }); +type BoolMap = { [key: string]: boolean }; + +export const mutateSetPluginsEnabled = (enabledMap: BoolMap) => + client.mutate({ + mutation: GQL.SetPluginsEnabledDocument, + variables: { enabledMap }, + }); + /// Tasks export const mutateMetadataScan = (input: GQL.ScanMetadataInput) => diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 9dfb9fc48c5..8c03f07f3a8 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -34,12 +34,14 @@ "delete_file_and_funscript": "Delete file (and funscript)", "delete_generated_supporting_files": "Delete generated supporting files", "delete_stashid": "Delete StashID", + "disable": "Disable", "disallow": "Disallow", "download": "Download", "download_anonymised": "Download anonymised", "download_backup": "Download Backup", "edit": "Edit", "edit_entity": "Edit {entityType}", + "enable": "Enable", "encoding_image": "Encoding image", "export": "Export", "export_all": "Export all…",