Add support for disabling plugins (#4141)

* Move timestamp to own file
* Backend changes
* UI changes
This commit is contained in:
WithoutPants 2023-10-16 16:15:12 +11:00 committed by GitHub
parent e5af37efbc
commit b6808dc714
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 260 additions and 62 deletions

View file

@ -33,6 +33,8 @@ models:
model: github.com/99designs/gqlgen/graphql.Int64 model: github.com/99designs/gqlgen/graphql.Int64
Timestamp: Timestamp:
model: github.com/stashapp/stash/internal/api.Timestamp model: github.com/stashapp/stash/internal/api.Timestamp
BoolMap:
model: github.com/stashapp/stash/internal/api.BoolMap
# define to force resolvers # define to force resolvers
Image: Image:
model: github.com/stashapp/stash/pkg/models.Image model: github.com/stashapp/stash/pkg/models.Image

View file

@ -9,3 +9,7 @@ mutation RunPluginTask(
) { ) {
runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args: $args) runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args: $args)
} }
mutation SetPluginsEnabled($enabledMap: BoolMap!) {
setPluginsEnabled(enabledMap: $enabledMap)
}

View file

@ -2,6 +2,7 @@ query Plugins {
plugins { plugins {
id id
name name
enabled
description description
url url
version version
@ -26,6 +27,7 @@ query PluginTasks {
plugin { plugin {
id id
name name
enabled
} }
} }
} }

View file

@ -394,6 +394,12 @@ type Mutation {
"Reload scrapers" "Reload scrapers"
reloadScrapers: Boolean! 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" "Run plugin task. Returns the job ID"
runPluginTask( runPluginTask(
plugin_id: ID! plugin_id: ID!

View file

@ -5,6 +5,8 @@ type Plugin {
url: String url: String
version: String version: String
enabled: Boolean!
tasks: [PluginTask!] tasks: [PluginTask!]
hooks: [PluginHook!] hooks: [PluginHook!]
} }

View file

@ -8,6 +8,9 @@ scalar Timestamp
# generic JSON object # generic JSON object
scalar Map scalar Map
# string, boolean map
scalar BoolMap
scalar Any scalar Any
scalar Int64 scalar Int64

38
internal/api/bool_map.go Normal file
View file

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

View file

@ -1,16 +1,7 @@
package api package api
import ( 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/models"
"github.com/stashapp/stash/pkg/utils"
) )
type BaseFile interface{} type BaseFile interface{}
@ -18,47 +9,3 @@ type BaseFile interface{}
type GalleryFile struct { type GalleryFile struct {
*models.BaseFile *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)
}

View file

@ -4,8 +4,10 @@ import (
"context" "context"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/plugin" "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) { 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 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
}

View file

@ -295,6 +295,10 @@ func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.Respo
var paths []string var paths []string
for _, p := range pluginCache.ListPlugins() { for _, p := range pluginCache.ListPlugins() {
if !p.Enabled {
continue
}
paths = append(paths, p.UI.CSS...) paths = append(paths, p.UI.CSS...)
} }
@ -318,6 +322,10 @@ func javascriptHandler(c *config.Instance, pluginCache *plugin.Cache) func(w htt
var paths []string var paths []string
for _, p := range pluginCache.ListPlugins() { for _, p := range pluginCache.ListPlugins() {
if !p.Enabled {
continue
}
paths = append(paths, p.UI.Javascript...) paths = append(paths, p.UI.Javascript...)
} }

57
internal/api/timestamp.go Normal file
View file

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

View file

@ -131,7 +131,8 @@ const (
PythonPath = "python_path" PythonPath = "python_path"
// plugin options // plugin options
PluginsPath = "plugins_path" PluginsPath = "plugins_path"
DisabledPlugins = "plugins.disabled"
// i18n // i18n
Language = "language" Language = "language"
@ -722,6 +723,10 @@ func (i *Instance) GetPluginsPath() string {
return i.getString(PluginsPath) return i.getString(PluginsPath)
} }
func (i *Instance) GetDisabledPlugins() []string {
return i.getStringSlice(DisabledPlugins)
}
func (i *Instance) GetPythonPath() string { func (i *Instance) GetPythonPath() string {
return i.getString(PythonPath) return i.getString(PythonPath)
} }

View file

@ -32,6 +32,8 @@ type Plugin struct {
Tasks []*PluginTask `json:"tasks"` Tasks []*PluginTask `json:"tasks"`
Hooks []*PluginHook `json:"hooks"` Hooks []*PluginHook `json:"hooks"`
UI PluginUI `json:"ui"` UI PluginUI `json:"ui"`
Enabled bool `json:"enabled"`
} }
type PluginUI struct { type PluginUI struct {
@ -48,6 +50,7 @@ type ServerConfig interface {
GetConfigPath() string GetConfigPath() string
HasTLSConfig() bool HasTLSConfig() bool
GetPluginsPath() string GetPluginsPath() string
GetDisabledPlugins() []string
GetPythonPath() string GetPythonPath() string
} }
@ -122,11 +125,39 @@ func loadPlugins(path string) ([]Config, error) {
return plugins, nil 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. // ListPlugins returns plugin details for all of the loaded plugins.
func (c Cache) ListPlugins() []*Plugin { func (c Cache) ListPlugins() []*Plugin {
disabledPlugins := c.config.GetDisabledPlugins()
var ret []*Plugin var ret []*Plugin
for _, s := range c.plugins { 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 return ret
@ -135,7 +166,7 @@ func (c Cache) ListPlugins() []*Plugin {
// ListPluginTasks returns all runnable plugin tasks in all loaded plugins. // ListPluginTasks returns all runnable plugin tasks in all loaded plugins.
func (c Cache) ListPluginTasks() []*PluginTask { func (c Cache) ListPluginTasks() []*PluginTask {
var ret []*PluginTask var ret []*PluginTask
for _, s := range c.plugins { for _, s := range c.enabledPlugins() {
ret = append(ret, s.getPluginTasks(true)...) 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) { func (c Cache) CreateTask(ctx context.Context, pluginID string, operationName string, args []*PluginArgInput, progress chan float64) (Task, error) {
serverConnection := c.makeServerConnection(ctx) serverConnection := c.makeServerConnection(ctx)
if c.pluginDisabled(pluginID) {
return nil, fmt.Errorf("plugin %s is disabled", pluginID)
}
// find the plugin and operation // find the plugin and operation
plugin := c.getPlugin(pluginID) 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 { func (c Cache) executePostHooks(ctx context.Context, hookType HookTriggerEnum, hookContext common.HookContext) error {
visitedPlugins := session.GetVisitedPlugins(ctx) visitedPlugins := session.GetVisitedPlugins(ctx)
for _, p := range c.plugins { for _, p := range c.enabledPlugins() {
hooks := p.getHooks(hookType) hooks := p.getHooks(hookType)
// don't revisit a plugin we've already visited // don't revisit a plugin we've already visited
// only log if there's hooks that we're skipping // only log if there's hooks that we're skipping

View file

@ -2,7 +2,11 @@ import React, { useMemo } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; 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 { useToast } from "src/hooks/Toast";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { CollapseButton } from "../Shared/CollapseButton"; import { CollapseButton } from "../Shared/CollapseButton";
@ -16,7 +20,11 @@ export const SettingsPluginsPanel: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const { data, loading } = usePlugins(); const [changedPluginID, setChangedPluginID] = React.useState<
string | undefined
>();
const { data, loading, refetch } = usePlugins();
async function onReloadPlugins() { async function onReloadPlugins() {
await mutateReloadPlugins().catch((e) => Toast.error(e)); 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 (
<Button size="sm" onClick={onClick}>
<FormattedMessage
id={enabled ? "actions.disable" : "actions.enable"}
/>
</Button>
);
}
function onReloadUI() {
window.location.reload();
}
function maybeRenderReloadUI(pluginID: string) {
if (pluginID === changedPluginID) {
return (
<Button size="sm" onClick={() => onReloadUI()}>
Reload UI
</Button>
);
}
}
function renderPlugins() { function renderPlugins() {
const elements = (data?.plugins ?? []).map((plugin) => ( const elements = (data?.plugins ?? []).map((plugin) => (
<SettingGroup <SettingGroup
@ -48,9 +89,16 @@ export const SettingsPluginsPanel: React.FC = () => {
heading: `${plugin.name} ${ heading: `${plugin.name} ${
plugin.version ? `(${plugin.version})` : undefined plugin.version ? `(${plugin.version})` : undefined
}`, }`,
className: !plugin.enabled ? "disabled" : undefined,
subHeading: plugin.description, 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)} {renderPluginHooks(plugin.hooks ?? undefined)}
</SettingGroup> </SettingGroup>
@ -98,7 +146,7 @@ export const SettingsPluginsPanel: React.FC = () => {
} }
return renderPlugins(); return renderPlugins();
}, [data?.plugins, intl]); }, [data?.plugins, intl, Toast, changedPluginID, refetch]);
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;

View file

@ -22,7 +22,7 @@ export const PluginTasks: React.FC = () => {
} }
const taskPlugins = plugins.data.plugins.filter( const taskPlugins = plugins.data.plugins.filter(
(p) => p.tasks && p.tasks.length > 0 (p) => p.enabled && p.tasks && p.tasks.length > 0
); );
return ( return (

View file

@ -2088,6 +2088,14 @@ export const mutateMigrate = (input: GQL.MigrateInput) =>
}, },
}); });
type BoolMap = { [key: string]: boolean };
export const mutateSetPluginsEnabled = (enabledMap: BoolMap) =>
client.mutate<GQL.SetPluginsEnabledMutation>({
mutation: GQL.SetPluginsEnabledDocument,
variables: { enabledMap },
});
/// Tasks /// Tasks
export const mutateMetadataScan = (input: GQL.ScanMetadataInput) => export const mutateMetadataScan = (input: GQL.ScanMetadataInput) =>

View file

@ -34,12 +34,14 @@
"delete_file_and_funscript": "Delete file (and funscript)", "delete_file_and_funscript": "Delete file (and funscript)",
"delete_generated_supporting_files": "Delete generated supporting files", "delete_generated_supporting_files": "Delete generated supporting files",
"delete_stashid": "Delete StashID", "delete_stashid": "Delete StashID",
"disable": "Disable",
"disallow": "Disallow", "disallow": "Disallow",
"download": "Download", "download": "Download",
"download_anonymised": "Download anonymised", "download_anonymised": "Download anonymised",
"download_backup": "Download Backup", "download_backup": "Download Backup",
"edit": "Edit", "edit": "Edit",
"edit_entity": "Edit {entityType}", "edit_entity": "Edit {entityType}",
"enable": "Enable",
"encoding_image": "Encoding image", "encoding_image": "Encoding image",
"export": "Export", "export": "Export",
"export_all": "Export all…", "export_all": "Export all…",