mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
UI plugin dependencies (#4307)
* Add requires field to UI plugin config * Use defer instead of async for useScript * Load plugins based on dependency * Document new field
This commit is contained in:
parent
910ff27730
commit
11be56cc42
8 changed files with 94 additions and 11 deletions
|
|
@ -25,6 +25,8 @@ query Plugins {
|
||||||
type
|
type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requires
|
||||||
|
|
||||||
paths {
|
paths {
|
||||||
css
|
css
|
||||||
javascript
|
javascript
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ type Plugin {
|
||||||
hooks: [PluginHook!]
|
hooks: [PluginHook!]
|
||||||
settings: [PluginSetting!]
|
settings: [PluginSetting!]
|
||||||
|
|
||||||
|
"""
|
||||||
|
Plugin IDs of plugins that this plugin depends on.
|
||||||
|
Applies only for UI plugins to indicate css/javascript load order.
|
||||||
|
"""
|
||||||
|
requires: [ID!]
|
||||||
|
|
||||||
paths: PluginPaths!
|
paths: PluginPaths!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,7 @@ func (r *pluginResolver) Paths(ctx context.Context, obj *plugin.Plugin) (*Plugin
|
||||||
|
|
||||||
return b.paths(), nil
|
return b.paths(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *pluginResolver) Requires(ctx context.Context, obj *plugin.Plugin) ([]string, error) {
|
||||||
|
return obj.UI.Requires, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ type PluginCSP struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type UIConfig struct {
|
type UIConfig struct {
|
||||||
|
// Requires is a list of plugin IDs that this plugin depends on.
|
||||||
|
// These plugins will be loaded before this plugin.
|
||||||
|
Requires []string `yaml:"requires"`
|
||||||
|
|
||||||
// Content Security Policy configuration for the plugin.
|
// Content Security Policy configuration for the plugin.
|
||||||
CSP PluginCSP `yaml:"csp"`
|
CSP PluginCSP `yaml:"csp"`
|
||||||
|
|
||||||
|
|
@ -239,6 +243,7 @@ func (c Config) toPlugin() *Plugin {
|
||||||
Tasks: c.getPluginTasks(false),
|
Tasks: c.getPluginTasks(false),
|
||||||
Hooks: c.getPluginHooks(false),
|
Hooks: c.getPluginHooks(false),
|
||||||
UI: PluginUI{
|
UI: PluginUI{
|
||||||
|
Requires: c.UI.Requires,
|
||||||
ExternalScript: c.UI.getExternalScripts(),
|
ExternalScript: c.UI.getExternalScripts(),
|
||||||
ExternalCSS: c.UI.getExternalCSS(),
|
ExternalCSS: c.UI.getExternalCSS(),
|
||||||
Javascript: c.UI.getJavascriptFiles(c),
|
Javascript: c.UI.getJavascriptFiles(c),
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ type Plugin struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginUI struct {
|
type PluginUI struct {
|
||||||
|
// Requires is a list of plugin IDs that this plugin depends on.
|
||||||
|
// These plugins will be loaded before this plugin.
|
||||||
|
Requires []string `json:"requires"`
|
||||||
|
|
||||||
// Content Security Policy configuration for the plugin.
|
// Content Security Policy configuration for the plugin.
|
||||||
CSP PluginCSP `json:"csp"`
|
CSP PluginCSP `json:"csp"`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,54 @@ function languageMessageString(language: string) {
|
||||||
return language.replace(/-/, "");
|
return language.replace(/-/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PluginList = NonNullable<Required<GQL.PluginsQuery["plugins"]>>;
|
||||||
|
|
||||||
|
// sort plugins by their dependencies
|
||||||
|
function sortPlugins(plugins: PluginList) {
|
||||||
|
type Node = { id: string; afters: string[] };
|
||||||
|
|
||||||
|
let nodes: Record<string, Node> = {};
|
||||||
|
let sorted: PluginList = [];
|
||||||
|
let visited: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
plugins.forEach((v) => {
|
||||||
|
let from = v.id;
|
||||||
|
|
||||||
|
if (!nodes[from]) nodes[from] = { id: from, afters: [] };
|
||||||
|
|
||||||
|
v.requires?.forEach((to) => {
|
||||||
|
if (!nodes[to]) nodes[to] = { id: to, afters: [] };
|
||||||
|
if (!nodes[to].afters.includes(from)) nodes[to].afters.push(from);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function visit(idstr: string, ancestors: string[] = []) {
|
||||||
|
let node = nodes[idstr];
|
||||||
|
const { id } = node;
|
||||||
|
|
||||||
|
if (visited[idstr]) return;
|
||||||
|
|
||||||
|
ancestors.push(id);
|
||||||
|
visited[idstr] = true;
|
||||||
|
node.afters.forEach(function (afterID) {
|
||||||
|
if (ancestors.indexOf(afterID) >= 0)
|
||||||
|
throw new Error("closed chain : " + afterID + " is in " + id);
|
||||||
|
visit(afterID.toString(), ancestors.slice());
|
||||||
|
});
|
||||||
|
|
||||||
|
const plugin = plugins.find((v) => v.id === id);
|
||||||
|
if (plugin) {
|
||||||
|
sorted.unshift(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(nodes).forEach((n) => {
|
||||||
|
visit(n);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const config = useConfiguration();
|
const config = useConfiguration();
|
||||||
const [saveUI] = useConfigureUI();
|
const [saveUI] = useConfigureUI();
|
||||||
|
|
@ -159,29 +207,36 @@ export const App: React.FC = () => {
|
||||||
error: pluginsError,
|
error: pluginsError,
|
||||||
} = usePlugins();
|
} = usePlugins();
|
||||||
|
|
||||||
const pluginJavascripts = useMemoOnce(() => {
|
const sortedPlugins = useMemoOnce(() => {
|
||||||
return [
|
return [
|
||||||
uniq(
|
sortPlugins(plugins?.plugins ?? []),
|
||||||
plugins?.plugins
|
|
||||||
?.filter((plugin) => plugin.enabled && plugin.paths.javascript)
|
|
||||||
.map((plugin) => plugin.paths.javascript!)
|
|
||||||
.flat() ?? []
|
|
||||||
),
|
|
||||||
!pluginsLoading && !pluginsError,
|
!pluginsLoading && !pluginsError,
|
||||||
];
|
];
|
||||||
}, [plugins?.plugins, pluginsLoading, pluginsError]);
|
}, [plugins?.plugins, pluginsLoading, pluginsError]);
|
||||||
|
|
||||||
|
const pluginJavascripts = useMemoOnce(() => {
|
||||||
|
return [
|
||||||
|
uniq(
|
||||||
|
sortedPlugins
|
||||||
|
?.filter((plugin) => plugin.enabled && plugin.paths.javascript)
|
||||||
|
.map((plugin) => plugin.paths.javascript!)
|
||||||
|
.flat() ?? []
|
||||||
|
),
|
||||||
|
!!sortedPlugins && !pluginsLoading && !pluginsError,
|
||||||
|
];
|
||||||
|
}, [sortedPlugins, pluginsLoading, pluginsError]);
|
||||||
|
|
||||||
const pluginCSS = useMemoOnce(() => {
|
const pluginCSS = useMemoOnce(() => {
|
||||||
return [
|
return [
|
||||||
uniq(
|
uniq(
|
||||||
plugins?.plugins
|
sortedPlugins
|
||||||
?.filter((plugin) => plugin.enabled && plugin.paths.css)
|
?.filter((plugin) => plugin.enabled && plugin.paths.css)
|
||||||
.map((plugin) => plugin.paths.css!)
|
.map((plugin) => plugin.paths.css!)
|
||||||
.flat() ?? []
|
.flat() ?? []
|
||||||
),
|
),
|
||||||
!pluginsLoading && !pluginsError,
|
!!sortedPlugins && !pluginsLoading && !pluginsError,
|
||||||
];
|
];
|
||||||
}, [plugins, pluginsLoading, pluginsError]);
|
}, [sortedPlugins, pluginsLoading, pluginsError]);
|
||||||
|
|
||||||
useScript(pluginJavascripts ?? [], !pluginsLoading && !pluginsError);
|
useScript(pluginJavascripts ?? [], !pluginsLoading && !pluginsError);
|
||||||
useCSS(pluginCSS ?? [], !pluginsLoading && !pluginsError);
|
useCSS(pluginCSS ?? [], !pluginsLoading && !pluginsError);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ ui:
|
||||||
javascript:
|
javascript:
|
||||||
- <path to javascript file>
|
- <path to javascript file>
|
||||||
|
|
||||||
|
# optional list of plugin IDs to load prior to this plugin
|
||||||
|
requires:
|
||||||
|
- <plugin ID>
|
||||||
|
|
||||||
# optional list of assets
|
# optional list of assets
|
||||||
assets:
|
assets:
|
||||||
urlPrefix: fsLocation
|
urlPrefix: fsLocation
|
||||||
|
|
@ -77,6 +81,9 @@ The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins w
|
||||||
The `css` and `javascript` field values may be relative paths to the plugin configuration file, or
|
The `css` and `javascript` field values may be relative paths to the plugin configuration file, or
|
||||||
may be full external URLs.
|
may be full external URLs.
|
||||||
|
|
||||||
|
The `requires` field is a list of plugin IDs which must have their javascript/css files loaded
|
||||||
|
before this plugins javascript/css files.
|
||||||
|
|
||||||
The `assets` field is a map of URL prefixes to filesystem paths relative to the plugin configuration file.
|
The `assets` field is a map of URL prefixes to filesystem paths relative to the plugin configuration file.
|
||||||
Assets are mounted to the `/plugin/{pluginID}/assets` path.
|
Assets are mounted to the `/plugin/{pluginID}/assets` path.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const useScript = (urls: string | string[], condition?: boolean) => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
|
|
||||||
script.src = url;
|
script.src = url;
|
||||||
script.async = true;
|
script.defer = true;
|
||||||
return script;
|
return script;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue