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

View file

@ -9,3 +9,7 @@ mutation RunPluginTask(
) {
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 {
id
name
enabled
description
url
version
@ -26,6 +27,7 @@ query PluginTasks {
plugin {
id
name
enabled
}
}
}

View file

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

View file

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

View file

@ -8,6 +8,9 @@ scalar Timestamp
# generic JSON object
scalar Map
# string, boolean map
scalar BoolMap
scalar Any
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
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)
}

View file

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

View file

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

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

@ -132,6 +132,7 @@ const (
// plugin options
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)
}

View file

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

View file

@ -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 (
<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() {
const elements = (data?.plugins ?? []).map((plugin) => (
<SettingGroup
@ -48,9 +89,16 @@ export const SettingsPluginsPanel: React.FC = () => {
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)}
</SettingGroup>
@ -98,7 +146,7 @@ export const SettingsPluginsPanel: React.FC = () => {
}
return renderPlugins();
}, [data?.plugins, intl]);
}, [data?.plugins, intl, Toast, changedPluginID, refetch]);
if (loading) return <LoadingIndicator />;

View file

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

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
export const mutateMetadataScan = (input: GQL.ScanMetadataInput) =>

View file

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