Package manager UI-related tweaks (#4382)

* Add Plugins Path setting
* Fix/improve cache invalidation
* Hide load error when collapsing package source
* Package manager style tweaks
* Show error if installed packages query failed
* Prevent "No packages found" flicker
* Show <unknown> if empty version
* Always show latest version, highlight if new version available
* Fix issues with non-unique cross-source package ids
* Don't wrap id, version and date
* Decrease collapse button padding
* Display description for scraper packages
* Fix default packages population
* Change default package path to community
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
DingDongSoLong4 2023-12-22 05:05:53 +02:00 committed by GitHub
parent 23b4d4f1e0
commit a1bd7cf817
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 611 additions and 500 deletions

View file

@ -9,6 +9,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
generatedPath generatedPath
metadataPath metadataPath
scrapersPath scrapersPath
pluginsPath
cachePath cachePath
blobsPath blobsPath
blobsStorage blobsStorage

View file

@ -55,7 +55,7 @@ query InstalledPluginPackages {
query InstalledPluginPackagesStatus { query InstalledPluginPackagesStatus {
installedPackages(type: Plugin) { installedPackages(type: Plugin) {
...PackageData ...PackageData
upgrade { source_package {
...PackageData ...PackageData
} }
} }

View file

@ -129,7 +129,7 @@ query InstalledScraperPackages {
query InstalledScraperPackagesStatus { query InstalledScraperPackagesStatus {
installedPackages(type: Scraper) { installedPackages(type: Scraper) {
...PackageData ...PackageData
upgrade { source_package {
...PackageData ...PackageData
} }
} }

View file

@ -73,6 +73,8 @@ input ConfigGeneralInput {
metadataPath: String metadataPath: String
"Path to scrapers" "Path to scrapers"
scrapersPath: String scrapersPath: String
"Path to plugins"
pluginsPath: String
"Path to cache" "Path to cache"
cachePath: String cachePath: String
"Path to blobs - required for filesystem blob storage" "Path to blobs - required for filesystem blob storage"
@ -189,6 +191,8 @@ type ConfigGeneralResult {
configFilePath: String! configFilePath: String!
"Path to scrapers" "Path to scrapers"
scrapersPath: String! scrapersPath: String!
"Path to plugins"
pluginsPath: String!
"Path to cache" "Path to cache"
cachePath: String! cachePath: String!
"Path to blobs - required for filesystem blob storage" "Path to blobs - required for filesystem blob storage"

View file

@ -12,8 +12,8 @@ type Package {
sourceURL: String! sourceURL: String!
"The available upgraded version of this package" "The version of this package currently available from the remote source"
upgrade: Package source_package: Package
metadata: Map! metadata: Map!
} }

View file

@ -104,6 +104,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
} }
refreshScraperCache := false refreshScraperCache := false
refreshScraperSource := false
existingScrapersPath := c.GetScrapersPath() existingScrapersPath := c.GetScrapersPath()
if input.ScrapersPath != nil && existingScrapersPath != *input.ScrapersPath { if input.ScrapersPath != nil && existingScrapersPath != *input.ScrapersPath {
if err := validateDir(config.ScrapersPath, *input.ScrapersPath, false); err != nil { if err := validateDir(config.ScrapersPath, *input.ScrapersPath, false); err != nil {
@ -111,9 +112,23 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
} }
refreshScraperCache = true refreshScraperCache = true
refreshScraperSource = true
c.Set(config.ScrapersPath, input.ScrapersPath) c.Set(config.ScrapersPath, input.ScrapersPath)
} }
refreshPluginCache := false
refreshPluginSource := false
existingPluginsPath := c.GetPluginsPath()
if input.PluginsPath != nil && existingPluginsPath != *input.PluginsPath {
if err := validateDir(config.PluginsPath, *input.PluginsPath, false); err != nil {
return makeConfigGeneralResult(), err
}
refreshPluginCache = true
refreshPluginSource = true
c.Set(config.PluginsPath, input.PluginsPath)
}
existingMetadataPath := c.GetMetadataPath() existingMetadataPath := c.GetMetadataPath()
if input.MetadataPath != nil && existingMetadataPath != *input.MetadataPath { if input.MetadataPath != nil && existingMetadataPath != *input.MetadataPath {
if err := validateDir(config.Metadata, *input.MetadataPath, true); err != nil { if err := validateDir(config.Metadata, *input.MetadataPath, true); err != nil {
@ -347,13 +362,11 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
c.Set(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange) c.Set(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
} }
refreshScraperSource := false
if input.ScraperPackageSources != nil { if input.ScraperPackageSources != nil {
c.Set(config.ScraperPackageSources, input.ScraperPackageSources) c.Set(config.ScraperPackageSources, input.ScraperPackageSources)
refreshScraperSource = true refreshScraperSource = true
} }
refreshPluginSource := false
if input.PluginPackageSources != nil { if input.PluginPackageSources != nil {
c.Set(config.PluginPackageSources, input.PluginPackageSources) c.Set(config.PluginPackageSources, input.PluginPackageSources)
refreshPluginSource = true refreshPluginSource = true
@ -367,6 +380,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if refreshScraperCache { if refreshScraperCache {
manager.GetInstance().RefreshScraperCache() manager.GetInstance().RefreshScraperCache()
} }
if refreshPluginCache {
manager.GetInstance().RefreshPluginCache()
}
if refreshStreamManager { if refreshStreamManager {
manager.GetInstance().RefreshStreamManager() manager.GetInstance().RefreshStreamManager()
} }

View file

@ -87,6 +87,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
MetadataPath: config.GetMetadataPath(), MetadataPath: config.GetMetadataPath(),
ConfigFilePath: config.GetConfigFile(), ConfigFilePath: config.GetConfigFile(),
ScrapersPath: config.GetScrapersPath(), ScrapersPath: config.GetScrapersPath(),
PluginsPath: config.GetPluginsPath(),
CachePath: config.GetCachePath(), CachePath: config.GetCachePath(),
BlobsPath: config.GetBlobsPath(), BlobsPath: config.GetBlobsPath(),
BlobsStorage: config.GetBlobsStorage(), BlobsStorage: config.GetBlobsStorage(),

View file

@ -98,11 +98,24 @@ func sortedPackageSpecKeys[V any](m map[models.PackageSpecInput]V) []models.Pack
} }
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
if strings.EqualFold(keys[i].ID, keys[j].ID) { a := keys[i]
return keys[i].ID < keys[j].ID b := keys[j]
aID := a.ID
bID := b.ID
if aID == bID {
return a.SourceURL < b.SourceURL
} }
return strings.ToLower(keys[i].ID) < strings.ToLower(keys[j].ID) aIDL := strings.ToLower(aID)
bIDL := strings.ToLower(bID)
if aIDL == bIDL {
return aID < bID
}
return aIDL < bIDL
}) })
return keys return keys
@ -129,9 +142,9 @@ func (r *queryResolver) getInstalledPackagesWithUpgrades(ctx context.Context, pm
for _, k := range sortedPackageSpecKeys(packageStatusIndex) { for _, k := range sortedPackageSpecKeys(packageStatusIndex) {
v := packageStatusIndex[k] v := packageStatusIndex[k]
p := manifestToPackage(*v.Local) p := manifestToPackage(*v.Local)
if v.Upgradable() { if v.Remote != nil {
pp := remotePackageToPackage(*v.Remote, allRemoteList) pp := remotePackageToPackage(*v.Remote, allRemoteList)
p.Upgrade = pp p.SourcePackage = pp
} }
ret[i] = p ret[i] = p
i++ i++
@ -146,19 +159,19 @@ func (r *queryResolver) InstalledPackages(ctx context.Context, typeArg PackageTy
return nil, err return nil, err
} }
installed, err := pm.ListInstalled(ctx)
if err != nil {
return nil, err
}
var ret []*Package var ret []*Package
if sliceutil.Contains(graphql.CollectAllFields(ctx), "upgrade") { if sliceutil.Contains(graphql.CollectAllFields(ctx), "source_package") {
ret, err = r.getInstalledPackagesWithUpgrades(ctx, pm) ret, err = r.getInstalledPackagesWithUpgrades(ctx, pm)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} else { } else {
installed, err := pm.ListInstalled(ctx)
if err != nil {
return nil, err
}
ret = make([]*Package, len(installed)) ret = make([]*Package, len(installed))
i := 0 i := 0
for _, k := range sortedPackageSpecKeys(installed) { for _, k := range sortedPackageSpecKeys(installed) {

View file

@ -138,7 +138,7 @@ const (
PluginsSettingPrefix = PluginsSetting + "." PluginsSettingPrefix = PluginsSetting + "."
DisabledPlugins = "plugins.disabled" DisabledPlugins = "plugins.disabled"
sourceDefaultPath = "stable" sourceDefaultPath = "community"
sourceDefaultName = "Community (stable)" sourceDefaultName = "Community (stable)"
PluginPackageSources = "plugins.package_sources" PluginPackageSources = "plugins.package_sources"
@ -1666,16 +1666,16 @@ func (i *Config) setDefaultValues() {
i.main.SetDefault(NoProxy, noProxyDefault) i.main.SetDefault(NoProxy, noProxyDefault)
// set default package sources // set default package sources
i.main.SetDefault(PluginPackageSources, map[string]string{ i.main.SetDefault(PluginPackageSources, []map[string]string{{
"name": sourceDefaultName, "name": sourceDefaultName,
"url": pluginPackageSourcesDefault, "url": pluginPackageSourcesDefault,
"local_path": sourceDefaultPath, "localpath": sourceDefaultPath,
}) }})
i.main.SetDefault(ScraperPackageSources, map[string]string{ i.main.SetDefault(ScraperPackageSources, []map[string]string{{
"name": sourceDefaultName, "name": sourceDefaultName,
"url": scraperPackageSourcesDefault, "url": scraperPackageSourcesDefault,
"local_path": sourceDefaultPath, "localpath": sourceDefaultPath,
}) }})
} }
// setExistingSystemDefaults sets config options that are new and unset in an existing install, // setExistingSystemDefaults sets config options that are new and unset in an existing install,

View file

@ -1,14 +1,14 @@
import React, { useEffect, useState, useMemo } from "react"; import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
evictQueries, evictQueries,
getClient, getClient,
queryAvailablePluginPackages, queryAvailablePluginPackages,
useInstallPluginPackages,
useInstalledPluginPackages, useInstalledPluginPackages,
useInstalledPluginPackagesStatus, mutateInstallPluginPackages,
useUninstallPluginPackages, mutateUninstallPluginPackages,
useUpdatePluginPackages, mutateUpdatePluginPackages,
pluginMutationImpactedQueries,
} from "src/core/StashService"; } from "src/core/StashService";
import { useMonitorJob } from "src/utils/job"; import { useMonitorJob } from "src/utils/job";
import { import {
@ -20,95 +20,59 @@ import { useSettings } from "./context";
import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { SettingSection } from "./SettingSection"; import { SettingSection } from "./SettingSection";
const impactedPackageChangeQueries = [
GQL.PluginsDocument,
GQL.PluginTasksDocument,
GQL.InstalledPluginPackagesDocument,
GQL.InstalledPluginPackagesStatusDocument,
];
export const InstalledPluginPackages: React.FC = () => { export const InstalledPluginPackages: React.FC = () => {
const [loadUpgrades, setLoadUpgrades] = useState(false); const [loadUpgrades, setLoadUpgrades] = useState(false);
const [jobID, setJobID] = useState<string>(); const [jobID, setJobID] = useState<string>();
const { job } = useMonitorJob(jobID, () => onPackageChanges()); const { job } = useMonitorJob(jobID, () => onPackageChanges());
const { data: installedPlugins, refetch: refetchPackages1 } = const { data, previousData, refetch, loading, error } =
useInstalledPluginPackages({ useInstalledPluginPackages(loadUpgrades);
skip: loadUpgrades,
});
const {
data: withStatus,
refetch: refetchPackages2,
loading: statusLoading,
} = useInstalledPluginPackagesStatus({
skip: !loadUpgrades,
});
const [updatePackages] = useUpdatePluginPackages();
const [uninstallPackages] = useUninstallPluginPackages();
async function onUpdatePackages(packages: GQL.PackageSpecInput[]) { async function onUpdatePackages(packages: GQL.PackageSpecInput[]) {
const r = await updatePackages({ const r = await mutateUpdatePluginPackages(packages);
variables: {
packages,
},
});
setJobID(r.data?.updatePackages); setJobID(r.data?.updatePackages);
} }
async function onUninstallPackages(packages: GQL.PackageSpecInput[]) { async function onUninstallPackages(packages: GQL.PackageSpecInput[]) {
const r = await uninstallPackages({ const r = await mutateUninstallPluginPackages(packages);
variables: {
packages,
},
});
setJobID(r.data?.uninstallPackages); setJobID(r.data?.uninstallPackages);
} }
function refetchPackages() {
refetchPackages1();
refetchPackages2();
}
function onPackageChanges() { function onPackageChanges() {
// job is complete, refresh all local data // job is complete, refresh all local data
const ac = getClient(); const ac = getClient();
evictQueries(ac.cache, impactedPackageChangeQueries); evictQueries(ac.cache, pluginMutationImpactedQueries);
} }
function onCheckForUpdates() { function onCheckForUpdates() {
if (!loadUpgrades) { if (!loadUpgrades) {
setLoadUpgrades(true); setLoadUpgrades(true);
} else { } else {
refetchPackages(); refetch();
} }
} }
const installedPackages = useMemo(() => { // when loadUpgrades changes from false to true, data is set to undefined while the request is loading
if (withStatus?.installedPackages) { // so use previousData as a fallback, which will be the result when loadUpgrades was false,
return withStatus.installedPackages; // to prevent displaying a "No packages found" message
} const installedPackages =
data?.installedPackages ?? previousData?.installedPackages ?? [];
return installedPlugins?.installedPackages ?? [];
}, [installedPlugins, withStatus]);
const loading = !!job || statusLoading;
return ( return (
<SettingSection headingID="config.plugins.installed_plugins"> <SettingSection headingID="config.plugins.installed_plugins">
<div className="package-manager"> <div className="package-manager">
<InstalledPackages <InstalledPackages
loading={loading} loading={!!job || loading}
error={error?.message}
packages={installedPackages} packages={installedPackages}
onCheckForUpdates={onCheckForUpdates} onCheckForUpdates={onCheckForUpdates}
onUpdatePackages={(packages) => onUpdatePackages={(packages) =>
onUpdatePackages( onUpdatePackages(
packages.map((p) => ({ packages.map((p) => ({
id: p.package_id, id: p.package_id,
sourceURL: p.upgrade!.sourceURL, sourceURL: p.sourceURL,
})) }))
) )
} }
@ -120,7 +84,7 @@ export const InstalledPluginPackages: React.FC = () => {
})) }))
) )
} }
updatesLoaded={loadUpgrades} updatesLoaded={loadUpgrades && !loading}
/> />
</div> </div>
</SettingSection> </SettingSection>
@ -130,18 +94,11 @@ export const InstalledPluginPackages: React.FC = () => {
export const AvailablePluginPackages: React.FC = () => { export const AvailablePluginPackages: React.FC = () => {
const { general, loading: configLoading, error, saveGeneral } = useSettings(); const { general, loading: configLoading, error, saveGeneral } = useSettings();
const [sources, setSources] = useState<GQL.PackageSource[]>();
const [jobID, setJobID] = useState<string>(); const [jobID, setJobID] = useState<string>();
const { job } = useMonitorJob(jobID, () => onPackageChanges()); const { job } = useMonitorJob(jobID, () => onPackageChanges());
const [installPackages] = useInstallPluginPackages();
async function onInstallPackages(packages: GQL.PackageSpecInput[]) { async function onInstallPackages(packages: GQL.PackageSpecInput[]) {
const r = await installPackages({ const r = await mutateInstallPluginPackages(packages);
variables: {
packages,
},
});
setJobID(r.data?.installPackages); setJobID(r.data?.installPackages);
} }
@ -149,15 +106,9 @@ export const AvailablePluginPackages: React.FC = () => {
function onPackageChanges() { function onPackageChanges() {
// job is complete, refresh all local data // job is complete, refresh all local data
const ac = getClient(); const ac = getClient();
evictQueries(ac.cache, impactedPackageChangeQueries); evictQueries(ac.cache, pluginMutationImpactedQueries);
} }
useEffect(() => {
if (!sources && !configLoading && general.pluginPackageSources) {
setSources(general.pluginPackageSources);
}
}, [sources, configLoading, general.pluginPackageSources]);
async function loadSource(source: string): Promise<RemotePackage[]> { async function loadSource(source: string): Promise<RemotePackage[]> {
const { data } = await queryAvailablePluginPackages(source); const { data } = await queryAvailablePluginPackages(source);
return data.availablePackages; return data.availablePackages;
@ -167,10 +118,6 @@ export const AvailablePluginPackages: React.FC = () => {
saveGeneral({ saveGeneral({
pluginPackageSources: [...(general.pluginPackageSources ?? []), source], pluginPackageSources: [...(general.pluginPackageSources ?? []), source],
}); });
setSources((prev) => {
return [...(prev ?? []), source];
});
} }
function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) { function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) {
@ -179,10 +126,6 @@ export const AvailablePluginPackages: React.FC = () => {
s.url === existing.url ? changed : s s.url === existing.url ? changed : s
), ),
}); });
setSources((prev) => {
return prev?.map((s) => (s.url === existing.url ? changed : s));
});
} }
function deleteSource(source: GQL.PackageSource) { function deleteSource(source: GQL.PackageSource) {
@ -191,10 +134,6 @@ export const AvailablePluginPackages: React.FC = () => {
(s) => s.url !== source.url (s) => s.url !== source.url
), ),
}); });
setSources((prev) => {
return prev?.filter((s) => s.url !== source.url);
});
} }
function renderDescription(pkg: RemotePackage) { function renderDescription(pkg: RemotePackage) {
@ -208,6 +147,8 @@ export const AvailablePluginPackages: React.FC = () => {
const loading = !!job; const loading = !!job;
const sources = general?.pluginPackageSources ?? [];
return ( return (
<SettingSection headingID="config.plugins.available_plugins"> <SettingSection headingID="config.plugins.available_plugins">
<div className="package-manager"> <div className="package-manager">
@ -216,7 +157,7 @@ export const AvailablePluginPackages: React.FC = () => {
onInstallPackages={onInstallPackages} onInstallPackages={onInstallPackages}
renderDescription={renderDescription} renderDescription={renderDescription}
loadSource={(source) => loadSource(source)} loadSource={(source) => loadSource(source)}
sources={sources ?? []} sources={sources}
addSource={addSource} addSource={addSource}
editSource={editSource} editSource={editSource}
deleteSource={deleteSource} deleteSource={deleteSource}

View file

@ -1,14 +1,14 @@
import React, { useEffect, useState, useMemo } from "react"; import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
evictQueries, evictQueries,
getClient, getClient,
queryAvailableScraperPackages, queryAvailableScraperPackages,
useInstallScraperPackages,
useInstalledScraperPackages, useInstalledScraperPackages,
useInstalledScraperPackagesStatus, mutateUpdateScraperPackages,
useUninstallScraperPackages, mutateUninstallScraperPackages,
useUpdateScraperPackages, mutateInstallScraperPackages,
scraperMutationImpactedQueries,
} from "src/core/StashService"; } from "src/core/StashService";
import { useMonitorJob } from "src/utils/job"; import { useMonitorJob } from "src/utils/job";
import { import {
@ -20,96 +20,59 @@ import { useSettings } from "./context";
import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { SettingSection } from "./SettingSection"; import { SettingSection } from "./SettingSection";
const impactedPackageChangeQueries = [
GQL.ListPerformerScrapersDocument,
GQL.ListSceneScrapersDocument,
GQL.ListMovieScrapersDocument,
GQL.InstalledScraperPackagesDocument,
GQL.InstalledScraperPackagesStatusDocument,
];
export const InstalledScraperPackages: React.FC = () => { export const InstalledScraperPackages: React.FC = () => {
const [loadUpgrades, setLoadUpgrades] = useState(false); const [loadUpgrades, setLoadUpgrades] = useState(false);
const [jobID, setJobID] = useState<string>(); const [jobID, setJobID] = useState<string>();
const { job } = useMonitorJob(jobID, () => onPackageChanges()); const { job } = useMonitorJob(jobID, () => onPackageChanges());
const { data: installedScrapers, refetch: refetchPackages1 } = const { data, previousData, refetch, loading, error } =
useInstalledScraperPackages({ useInstalledScraperPackages(loadUpgrades);
skip: loadUpgrades,
});
const {
data: withStatus,
refetch: refetchPackages2,
loading: statusLoading,
} = useInstalledScraperPackagesStatus({
skip: !loadUpgrades,
});
const [updatePackages] = useUpdateScraperPackages();
const [uninstallPackages] = useUninstallScraperPackages();
async function onUpdatePackages(packages: GQL.PackageSpecInput[]) { async function onUpdatePackages(packages: GQL.PackageSpecInput[]) {
const r = await updatePackages({ const r = await mutateUpdateScraperPackages(packages);
variables: {
packages,
},
});
setJobID(r.data?.updatePackages); setJobID(r.data?.updatePackages);
} }
async function onUninstallPackages(packages: GQL.PackageSpecInput[]) { async function onUninstallPackages(packages: GQL.PackageSpecInput[]) {
const r = await uninstallPackages({ const r = await mutateUninstallScraperPackages(packages);
variables: {
packages,
},
});
setJobID(r.data?.uninstallPackages); setJobID(r.data?.uninstallPackages);
} }
function refetchPackages() {
refetchPackages1();
refetchPackages2();
}
function onPackageChanges() { function onPackageChanges() {
// job is complete, refresh all local data // job is complete, refresh all local data
const ac = getClient(); const ac = getClient();
evictQueries(ac.cache, impactedPackageChangeQueries); evictQueries(ac.cache, scraperMutationImpactedQueries);
} }
function onCheckForUpdates() { function onCheckForUpdates() {
if (!loadUpgrades) { if (!loadUpgrades) {
setLoadUpgrades(true); setLoadUpgrades(true);
} else { } else {
refetchPackages(); refetch();
} }
} }
const installedPackages = useMemo(() => { // when loadUpgrades changes from false to true, data is set to undefined while the request is loading
if (withStatus?.installedPackages) { // so use previousData as a fallback, which will be the result when loadUpgrades was false,
return withStatus.installedPackages; // to prevent displaying a "No packages found" message
} const installedPackages =
data?.installedPackages ?? previousData?.installedPackages ?? [];
return installedScrapers?.installedPackages ?? [];
}, [installedScrapers, withStatus]);
const loading = !!job || statusLoading;
return ( return (
<SettingSection headingID="config.scraping.installed_scrapers"> <SettingSection headingID="config.scraping.installed_scrapers">
<div className="package-manager"> <div className="package-manager">
<InstalledPackages <InstalledPackages
loading={loading} loading={!!job || loading}
error={error?.message}
packages={installedPackages} packages={installedPackages}
onCheckForUpdates={onCheckForUpdates} onCheckForUpdates={onCheckForUpdates}
onUpdatePackages={(packages) => onUpdatePackages={(packages) =>
onUpdatePackages( onUpdatePackages(
packages.map((p) => ({ packages.map((p) => ({
id: p.package_id, id: p.package_id,
sourceURL: p.upgrade!.sourceURL, sourceURL: p.sourceURL,
})) }))
) )
} }
@ -121,7 +84,7 @@ export const InstalledScraperPackages: React.FC = () => {
})) }))
) )
} }
updatesLoaded={loadUpgrades} updatesLoaded={loadUpgrades && !loading}
/> />
</div> </div>
</SettingSection> </SettingSection>
@ -131,18 +94,11 @@ export const InstalledScraperPackages: React.FC = () => {
export const AvailableScraperPackages: React.FC = () => { export const AvailableScraperPackages: React.FC = () => {
const { general, loading: configLoading, error, saveGeneral } = useSettings(); const { general, loading: configLoading, error, saveGeneral } = useSettings();
const [sources, setSources] = useState<GQL.PackageSource[]>();
const [jobID, setJobID] = useState<string>(); const [jobID, setJobID] = useState<string>();
const { job } = useMonitorJob(jobID, () => onPackageChanges()); const { job } = useMonitorJob(jobID, () => onPackageChanges());
const [installPackages] = useInstallScraperPackages();
async function onInstallPackages(packages: GQL.PackageSpecInput[]) { async function onInstallPackages(packages: GQL.PackageSpecInput[]) {
const r = await installPackages({ const r = await mutateInstallScraperPackages(packages);
variables: {
packages,
},
});
setJobID(r.data?.installPackages); setJobID(r.data?.installPackages);
} }
@ -150,15 +106,9 @@ export const AvailableScraperPackages: React.FC = () => {
function onPackageChanges() { function onPackageChanges() {
// job is complete, refresh all local data // job is complete, refresh all local data
const ac = getClient(); const ac = getClient();
evictQueries(ac.cache, impactedPackageChangeQueries); evictQueries(ac.cache, scraperMutationImpactedQueries);
} }
useEffect(() => {
if (!sources && !configLoading && general.scraperPackageSources) {
setSources(general.scraperPackageSources);
}
}, [sources, configLoading, general.scraperPackageSources]);
async function loadSource(source: string): Promise<RemotePackage[]> { async function loadSource(source: string): Promise<RemotePackage[]> {
const { data } = await queryAvailableScraperPackages(source); const { data } = await queryAvailableScraperPackages(source);
return data.availablePackages; return data.availablePackages;
@ -168,10 +118,6 @@ export const AvailableScraperPackages: React.FC = () => {
saveGeneral({ saveGeneral({
scraperPackageSources: [...(general.scraperPackageSources ?? []), source], scraperPackageSources: [...(general.scraperPackageSources ?? []), source],
}); });
setSources((prev) => {
return [...(prev ?? []), source];
});
} }
function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) { function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) {
@ -180,10 +126,6 @@ export const AvailableScraperPackages: React.FC = () => {
s.url === existing.url ? changed : s s.url === existing.url ? changed : s
), ),
}); });
setSources((prev) => {
return prev?.map((s) => (s.url === existing.url ? changed : s));
});
} }
function deleteSource(source: GQL.PackageSource) { function deleteSource(source: GQL.PackageSource) {
@ -192,10 +134,12 @@ export const AvailableScraperPackages: React.FC = () => {
(s) => s.url !== source.url (s) => s.url !== source.url
), ),
}); });
}
setSources((prev) => { function renderDescription(pkg: RemotePackage) {
return prev?.filter((s) => s.url !== source.url); if (pkg.metadata.description) {
}); return pkg.metadata.description;
}
} }
if (error) return <h1>{error.message}</h1>; if (error) return <h1>{error.message}</h1>;
@ -203,14 +147,17 @@ export const AvailableScraperPackages: React.FC = () => {
const loading = !!job; const loading = !!job;
const sources = general?.scraperPackageSources ?? [];
return ( return (
<SettingSection headingID="config.scraping.available_scrapers"> <SettingSection headingID="config.scraping.available_scrapers">
<div className="package-manager"> <div className="package-manager">
<AvailablePackages <AvailablePackages
loading={loading} loading={loading}
onInstallPackages={onInstallPackages} onInstallPackages={onInstallPackages}
renderDescription={renderDescription}
loadSource={(source) => loadSource(source)} loadSource={(source) => loadSource(source)}
sources={sources ?? []} sources={sources}
addSource={addSource} addSource={addSource}
editSource={editSource} editSource={editSource}
deleteSource={deleteSource} deleteSource={deleteSource}

View file

@ -137,6 +137,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
onChange={(v) => saveGeneral({ scrapersPath: v })} onChange={(v) => saveGeneral({ scrapersPath: v })}
/> />
<StringSetting
id="plugins-path"
headingID="config.general.plugins_path.heading"
subHeadingID="config.general.plugins_path.description"
value={general.pluginsPath ?? undefined}
onChange={(v) => saveGeneral({ pluginsPath: v })}
/>
<StringSetting <StringSetting
id="metadata-path" id="metadata-path"
headingID="config.general.metadata_path.heading" headingID="config.general.metadata_path.heading"

View file

@ -3,7 +3,9 @@ import React, { useState, useMemo, useEffect } from "react";
import { FormattedMessage, IntlShape, useIntl } from "react-intl"; import { FormattedMessage, IntlShape, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Icon } from "../Icon"; import { Icon } from "../Icon";
import cx from "classnames";
import { import {
faAnglesUp,
faChevronDown, faChevronDown,
faChevronRight, faChevronRight,
faRotate, faRotate,
@ -17,7 +19,19 @@ import { LoadingIndicator } from "../LoadingIndicator";
import { ApolloError } from "@apollo/client"; import { ApolloError } from "@apollo/client";
import { ClearableInput } from "../ClearableInput"; import { ClearableInput } from "../ClearableInput";
function formatDate(intl: IntlShape, date: string | undefined | null) { function packageKey(
pkg: Pick<GQL.Package, "package_id" | "sourceURL">
): string {
return `${pkg.sourceURL}-${pkg.package_id}`;
}
function displayVersion(intl: IntlShape, version: string | undefined | null) {
if (!version) return intl.formatMessage({ id: "package_manager.unknown" });
return version;
}
function displayDate(intl: IntlShape, date: string | undefined | null) {
if (!date) return; if (!date) return;
const d = new Date(date); const d = new Date(date);
@ -59,14 +73,17 @@ const InstalledPackageRow: React.FC<{
}> = ({ loading, pkg, selected, togglePackage, updatesLoaded }) => { }> = ({ loading, pkg, selected, togglePackage, updatesLoaded }) => {
const intl = useIntl(); const intl = useIntl();
function rowClassname() { const updateAvailable = useMemo(() => {
if (pkg.upgrade?.version) { if (!updatesLoaded) return false;
return "package-update-available"; if (!pkg.date || !pkg.source_package?.date) return false;
}
} const pkgDate = new Date(pkg.date);
const upgradeDate = new Date(pkg.source_package.date);
return upgradeDate > pkgDate;
}, [updatesLoaded, pkg]);
return ( return (
<tr className={rowClassname()}> <tr className={cx({ "package-update-available": updateAvailable })}>
<td> <td>
<Form.Check <Form.Check
checked={selected} checked={selected}
@ -79,17 +96,22 @@ const InstalledPackageRow: React.FC<{
<span className="package-id">{pkg.package_id}</span> <span className="package-id">{pkg.package_id}</span>
</td> </td>
<td> <td>
<span className="package-version">{pkg.version}</span> <span className="package-version">
<span className="package-date">{formatDate(intl, pkg.date)}</span> {displayVersion(intl, pkg.version)}
</span>
<span className="package-date">{displayDate(intl, pkg.date)}</span>
</td> </td>
{updatesLoaded ? ( {updatesLoaded && pkg.source_package && (
<td> <td>
<span className="package-version">{pkg.upgrade?.version}</span> <span className="package-latest-version">
<span className="package-date"> {displayVersion(intl, pkg.source_package.version)}
{formatDate(intl, pkg.upgrade?.date)} {updateAvailable && <Icon icon={faAnglesUp} />}
</span>
<span className="package-latest-date">
{displayDate(intl, pkg.source_package.date)}
</span> </span>
</td> </td>
) : undefined} )}
</tr> </tr>
); );
}; };
@ -97,6 +119,7 @@ const InstalledPackageRow: React.FC<{
const InstalledPackagesList: React.FC<{ const InstalledPackagesList: React.FC<{
filter: string; filter: string;
loading?: boolean; loading?: boolean;
error?: string;
updatesLoaded: boolean; updatesLoaded: boolean;
packages: InstalledPackage[]; packages: InstalledPackage[];
checkedPackages: InstalledPackage[]; checkedPackages: InstalledPackage[];
@ -108,12 +131,13 @@ const InstalledPackagesList: React.FC<{
setCheckedPackages, setCheckedPackages,
updatesLoaded, updatesLoaded,
loading, loading,
error,
}) => { }) => {
const checkedMap = useMemo(() => { const checkedMap = useMemo(() => {
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
checkedPackages.forEach((pkg) => { for (const pkg of checkedPackages) {
map[`${pkg.sourceURL}-${pkg.package_id}`] = true; map[packageKey(pkg)] = true;
}); }
return map; return map;
}, [checkedPackages]); }, [checkedPackages]);
@ -134,19 +158,54 @@ const InstalledPackagesList: React.FC<{
setCheckedPackages((prev) => { setCheckedPackages((prev) => {
if (prev.includes(pkg)) { if (prev.includes(pkg)) {
return prev.filter((n) => n.package_id !== pkg.package_id); return prev.filter((n) => packageKey(n) !== packageKey(pkg));
} else { } else {
return prev.concat(pkg); return [...prev, pkg];
} }
}); });
} }
function renderBody() {
if (error) {
return (
<tr>
<td />
<td colSpan={1000} className="source-error">
<Icon icon={faWarning} />
<span>{error}</span>
</td>
</tr>
);
}
if (filteredPackages.length === 0) {
return (
<tr className="package-manager-no-results">
<td colSpan={1000}>
<FormattedMessage id="package_manager.no_packages" />
</td>
</tr>
);
}
return filteredPackages.map((pkg) => (
<InstalledPackageRow
key={packageKey(pkg)}
loading={loading}
pkg={pkg}
selected={checkedMap[packageKey(pkg)] ?? false}
togglePackage={() => togglePackage(pkg)}
updatesLoaded={updatesLoaded}
/>
));
}
return ( return (
<div className="package-manager-table-container"> <div className="package-manager-table-container">
<Table> <Table>
<thead> <thead>
<tr> <tr>
<th className="button-cell"> <th className="check-cell">
<Form.Check <Form.Check
checked={allChecked ?? false} checked={allChecked ?? false}
onChange={toggleAllChecked} onChange={toggleAllChecked}
@ -166,28 +225,7 @@ const InstalledPackagesList: React.FC<{
) : undefined} ) : undefined}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>{renderBody()}</tbody>
{filteredPackages.length === 0 ? (
<tr className="package-manager-no-results">
<td colSpan={updatesLoaded ? 4 : 3}>
<FormattedMessage id="package_manager.no_packages" />
</td>
</tr>
) : (
filteredPackages.map((pkg) => (
<InstalledPackageRow
key={`${pkg.sourceURL}-${pkg.package_id}`}
loading={loading}
pkg={pkg}
selected={
checkedMap[`${pkg.sourceURL}-${pkg.package_id}`] ?? false
}
togglePackage={() => togglePackage(pkg)}
updatesLoaded={updatesLoaded}
/>
))
)}
</tbody>
</Table> </Table>
</div> </div>
); );
@ -213,42 +251,40 @@ const InstalledPackagesToolbar: React.FC<{
const intl = useIntl(); const intl = useIntl();
return ( return (
<div className="package-manager-toolbar"> <div className="package-manager-toolbar">
<div> <ClearableInput
<ClearableInput placeholder={`${intl.formatMessage({ id: "filter" })}...`}
placeholder={`${intl.formatMessage({ id: "filter" })}...`} value={filter}
value={filter} setValue={(v) => setFilter(v)}
setValue={(v) => setFilter(v)} />
/> <div className="flex-grow-1" />
</div> <Button
<div> variant="primary"
<Button onClick={() => onCheckForUpdates()}
variant="primary" disabled={loading}
onClick={() => onCheckForUpdates()} >
disabled={loading} <FormattedMessage id="package_manager.check_for_updates" />
> </Button>
<FormattedMessage id="package_manager.check_for_updates" /> <Button
</Button> variant="primary"
<Button disabled={!checkedPackages.length || loading}
variant="primary" onClick={() => onUpdatePackages()}
disabled={!checkedPackages.length || loading} >
onClick={() => onUpdatePackages()} <FormattedMessage id="package_manager.update" />
> </Button>
<FormattedMessage id="package_manager.update" /> <Button
</Button> variant="danger"
<Button disabled={!checkedPackages.length || loading}
variant="danger" onClick={() => onUninstallPackages()}
disabled={!checkedPackages.length || loading} >
onClick={() => onUninstallPackages()} <FormattedMessage id="package_manager.uninstall" />
> </Button>
<FormattedMessage id="package_manager.uninstall" />
</Button>
</div>
</div> </div>
); );
}; };
export const InstalledPackages: React.FC<{ export const InstalledPackages: React.FC<{
loading?: boolean; loading?: boolean;
error?: string;
packages: InstalledPackage[]; packages: InstalledPackage[];
updatesLoaded: boolean; updatesLoaded: boolean;
onCheckForUpdates: () => void; onCheckForUpdates: () => void;
@ -261,6 +297,7 @@ export const InstalledPackages: React.FC<{
onUpdatePackages, onUpdatePackages,
onUninstallPackages, onUninstallPackages,
loading, loading,
error,
}) => { }) => {
const [checkedPackages, setCheckedPackages] = useState<InstalledPackage[]>( const [checkedPackages, setCheckedPackages] = useState<InstalledPackage[]>(
[] []
@ -275,7 +312,7 @@ export const InstalledPackages: React.FC<{
useEffect(() => { useEffect(() => {
setCheckedPackages((prev) => { setCheckedPackages((prev) => {
const newVal = prev.filter((pkg) => const newVal = prev.filter((pkg) =>
packages.find((p) => p.package_id === pkg.package_id) packages.find((p) => packageKey(p) === packageKey(pkg))
); );
if (newVal.length !== prev.length) { if (newVal.length !== prev.length) {
return newVal; return newVal;
@ -316,6 +353,7 @@ export const InstalledPackages: React.FC<{
<InstalledPackagesList <InstalledPackagesList
filter={filter} filter={filter}
loading={loading} loading={loading}
error={error}
packages={packages} packages={packages}
// use original checked packages so that check boxes are not affected by filter // use original checked packages so that check boxes are not affected by filter
checkedPackages={checkedPackages} checkedPackages={checkedPackages}
@ -353,31 +391,28 @@ const AvailablePackagesToolbar: React.FC<{
return ( return (
<div className="package-manager-toolbar"> <div className="package-manager-toolbar">
<div> <ClearableInput
<ClearableInput placeholder={`${intl.formatMessage({ id: "filter" })}...`}
placeholder={`${intl.formatMessage({ id: "filter" })}...`} value={filter}
value={filter} setValue={(v) => setFilter(v)}
setValue={(v) => setFilter(v)} />
/> {hasSelectedPackages && (
{hasSelectedPackages && (
<Button
size="sm"
variant="primary"
onClick={() => setSelectedOnly(!selectedOnly)}
>
<FormattedMessage id={selectedOnlyId} />
</Button>
)}
</div>
<div>
<Button <Button
size="sm"
variant="primary" variant="primary"
disabled={!hasSelectedPackages || loading} onClick={() => setSelectedOnly(!selectedOnly)}
onClick={() => onInstallPackages()}
> >
<FormattedMessage id="package_manager.install" /> <FormattedMessage id={selectedOnlyId} />
</Button> </Button>
</div> )}
<div className="flex-grow-1" />
<Button
variant="primary"
disabled={!hasSelectedPackages || loading}
onClick={() => onInstallPackages()}
>
<FormattedMessage id="package_manager.install" />
</Button>
</div> </div>
); );
}; };
@ -552,7 +587,7 @@ const AvailablePackageRow: React.FC<{
} }
return ( return (
<tr key={pkg.package_id}> <tr>
<td colSpan={2}> <td colSpan={2}>
<Form.Check <Form.Check
checked={selected ?? false} checked={selected ?? false}
@ -565,8 +600,10 @@ const AvailablePackageRow: React.FC<{
<span className="package-id">{pkg.package_id}</span> <span className="package-id">{pkg.package_id}</span>
</td> </td>
<td> <td>
<span className="package-version">{pkg.version}</span> <span className="package-version">
<span className="package-date">{formatDate(intl, pkg.date)}</span> {displayVersion(intl, pkg.version)}
</span>
<span className="package-date">{displayDate(intl, pkg.date)}</span>
</td> </td>
<td> <td>
{renderRequiredBy()} {renderRequiredBy()}
@ -655,36 +692,62 @@ const SourcePackagesList: React.FC<{
} }
function toggleSourceOpen() { function toggleSourceOpen() {
if (packages === undefined) { if (sourceOpen) {
loadPackages(); setLoadError(undefined);
setSourceOpen(false);
} else {
if (packages === undefined) {
loadPackages();
}
setSourceOpen(true);
}
}
function renderContents() {
if (loading) {
return (
<tr>
<td colSpan={2}></td>
<td colSpan={3}>
<LoadingIndicator inline small />
</td>
</tr>
);
} }
setSourceOpen((prev) => !prev); if (loadError) {
} return (
<tr>
<td colSpan={2}></td>
<td colSpan={3} className="source-error">
<Icon icon={faWarning} />
<span>{loadError}</span>
<Button
size="sm"
variant="secondary"
onClick={() => loadPackages()}
title={intl.formatMessage({ id: "actions.reload" })}
>
<Icon icon={faRotate} />
</Button>
</td>
</tr>
);
}
function renderCollapseButton() { if (!sourceOpen) {
return ( return null;
<Button }
variant="minimal"
size="sm"
className="package-collapse-button"
onClick={() => toggleSourceOpen()}
>
<Icon icon={sourceOpen ? faChevronDown : faChevronRight} />
</Button>
);
}
const children = useMemo(() => {
function getRequiredPackages(pkg: RemotePackage) { function getRequiredPackages(pkg: RemotePackage) {
const ret: RemotePackage[] = []; const ret: RemotePackage[] = [];
pkg.requires.forEach((r) => { for (const r of pkg.requires) {
const found = packages?.find((p) => p.package_id === r.package_id); const found = packages?.find((p) => p.package_id === r.package_id);
if (found && !ret.includes(found)) { if (found && !ret.includes(found)) {
ret.push(found); ret.push(found);
ret.push(...getRequiredPackages(found)); ret.push(...getRequiredPackages(found));
} }
}); }
return ret; return ret;
} }
@ -698,10 +761,7 @@ const SourcePackagesList: React.FC<{
return prev.filter((n) => n.package_id !== pkg.package_id); return prev.filter((n) => n.package_id !== pkg.package_id);
} else { } else {
// also include required packages // also include required packages
const toAdd = [pkg]; return [...prev, pkg, ...getRequiredPackages(pkg)];
toAdd.push(...getRequiredPackages(pkg));
return prev.concat(...toAdd);
} }
}); });
} }
@ -711,27 +771,19 @@ const SourcePackagesList: React.FC<{
key={pkg.package_id} key={pkg.package_id}
disabled={disabled} disabled={disabled}
pkg={pkg} pkg={pkg}
requiredBy={selectedPackages.filter((p) => { requiredBy={selectedPackages.filter((p) =>
return p.requires.find((r) => r.package_id === pkg.package_id); p.requires.some((r) => r.package_id === pkg.package_id)
})} )}
selected={checkedMap[pkg.package_id] ?? false} selected={checkedMap[pkg.package_id] ?? false}
togglePackage={() => togglePackage(pkg)} togglePackage={() => togglePackage(pkg)}
renderDescription={renderDescription} renderDescription={renderDescription}
/> />
)); ));
}, [ }
filteredPackages,
disabled,
checkedMap,
selectedPackages,
setSelectedPackages,
packages,
renderDescription,
]);
return ( return (
<> <>
<tr key={source.url} className="package-source"> <tr className="package-source">
<td> <td>
{packages !== undefined ? ( {packages !== undefined ? (
<Form.Check <Form.Check
@ -741,8 +793,20 @@ const SourcePackagesList: React.FC<{
/> />
) : undefined} ) : undefined}
</td> </td>
<td>{renderCollapseButton()}</td> <td className="source-collapse">
<td colSpan={2} onClick={() => toggleSourceOpen()}> <Button
variant="minimal"
size="sm"
onClick={() => toggleSourceOpen()}
>
<Icon icon={sourceOpen ? faChevronDown : faChevronRight} />
</Button>
</td>
<td
className="source-name"
colSpan={2}
onClick={() => toggleSourceOpen()}
>
<span>{source.name ?? source.url}</span> <span>{source.name ?? source.url}</span>
</td> </td>
<td className="source-controls"> <td className="source-controls">
@ -764,32 +828,7 @@ const SourcePackagesList: React.FC<{
</Button> </Button>
</td> </td>
</tr> </tr>
{loading ? ( {renderContents()}
<tr>
<td colSpan={2}></td>
<td colSpan={3}>
<LoadingIndicator inline small />
</td>
</tr>
) : undefined}
{loadError ? (
<tr>
<td colSpan={2}></td>
<td colSpan={3} className="source-error">
<Icon icon={faWarning} />
<span>{loadError}</span>
<Button
size="sm"
variant="secondary"
onClick={() => loadPackages()}
title={intl.formatMessage({ id: "actions.reload" })}
>
<Icon icon={faRotate} />
</Button>
</td>
</tr>
) : undefined}
{sourceOpen && !loading && children}
</> </>
); );
}; };
@ -847,6 +886,58 @@ const AvailablePackagesList: React.FC<{
}); });
} }
function renderBody() {
if (sources.length === 0) {
return (
<tr className="package-manager-no-results">
<td colSpan={5}>
<FormattedMessage id="package_manager.no_sources" />
<br />
<Button
size="sm"
variant="success"
onClick={() => setAddingSource(true)}
>
<FormattedMessage id="package_manager.add_source" />
</Button>
</td>
</tr>
);
}
return (
<>
{sources.map((src) => (
<SourcePackagesList
key={src.url}
filter={filter}
disabled={loading}
source={src}
renderDescription={renderDescription}
loadSource={() => loadSource(src.url)}
selectedOnly={selectedOnly}
selectedPackages={selectedPackages[src.url] ?? []}
setSelectedPackages={(v) => setSelectedSourcePackages(src, v)}
editSource={() => setEditingSource(src)}
deleteSource={() => setDeletingSource(src)}
/>
))}
<tr className="add-package-source">
<td colSpan={2}></td>
<td colSpan={3}>
<Button
size="sm"
variant="success"
onClick={() => setAddingSource(true)}
>
<FormattedMessage id="package_manager.add_source" />
</Button>
</td>
</tr>
</>
);
}
return ( return (
<> <>
<AlertModal <AlertModal
@ -880,8 +971,8 @@ const AvailablePackagesList: React.FC<{
<Table> <Table>
<thead> <thead>
<tr> <tr>
<th className="button-cell"></th> <th className="check-cell"></th>
<th className="button-cell"></th> <th className="collapse-cell"></th>
<th> <th>
<FormattedMessage id="package_manager.package" /> <FormattedMessage id="package_manager.package" />
</th> </th>
@ -893,49 +984,7 @@ const AvailablePackagesList: React.FC<{
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>{renderBody()}</tbody>
{sources.length === 0 ? (
<tr className="package-manager-no-results">
<td colSpan={5}>
<FormattedMessage id="package_manager.no_sources" />
<br />
<Button
size="sm"
variant="success"
onClick={() => setAddingSource(true)}
>
<FormattedMessage id="package_manager.add_source" />
</Button>
</td>
</tr>
) : (
sources.map((src) => (
<SourcePackagesList
key={src.url}
filter={filter}
disabled={loading}
source={src}
renderDescription={renderDescription}
loadSource={() => loadSource(src.url)}
selectedOnly={selectedOnly}
selectedPackages={selectedPackages[src.url] ?? []}
setSelectedPackages={(v) => setSelectedSourcePackages(src, v)}
editSource={() => setEditingSource(src)}
deleteSource={() => setDeletingSource(src)}
/>
))
)}
{sources.length > 0 ? (
<tr className="package-source">
<td colSpan={2}></td>
<td colSpan={3} onClick={() => setAddingSource(true)}>
<Button size="sm" variant="success">
<FormattedMessage id="package_manager.add_source" />
</Button>
</td>
</tr>
) : undefined}
</tbody>
</Table> </Table>
</div> </div>
</> </>

View file

@ -4,9 +4,19 @@
.package-source { .package-source {
font-weight: bold; font-weight: bold;
.source-collapse {
padding-left: 0;
padding-right: 0;
.btn {
color: $text-color;
}
}
.source-controls { .source-controls {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 0.5rem;
justify-content: end; justify-content: end;
} }
} }
@ -16,10 +26,6 @@
cursor: pointer; cursor: pointer;
} }
.package-collapse-button {
color: $text-color;
}
.package-manager-table-container { .package-manager-table-container {
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
@ -31,39 +37,54 @@
top: 0; top: 0;
z-index: 1; z-index: 1;
.button-cell { .check-cell {
width: 40px; width: 40px;
} }
.collapse-cell {
width: 30px;
}
} }
table td { table td {
vertical-align: middle; vertical-align: middle;
} }
.package-name,
.package-id,
.package-version, .package-version,
.package-date, .package-date,
.package-name, .package-latest-version,
.package-id { .package-latest-date {
display: block; display: block;
} }
.package-id,
.package-date, .package-date,
.package-id { .package-latest-date {
color: $muted-gray; color: $muted-gray;
font-size: 0.8rem; font-size: 0.8rem;
} }
.package-id,
.package-version,
.package-date,
.package-latest-version,
.package-latest-date {
white-space: nowrap;
}
.package-update-available {
.package-latest-version,
.package-latest-date {
font-weight: 700;
}
}
.package-manager-toolbar { .package-manager-toolbar {
display: flex; display: flex;
justify-content: space-between; gap: 0.5rem;
padding-bottom: 0.25rem;
div {
display: flex;
}
.btn {
margin-left: 0.5em;
}
} }
.package-required-by { .package-required-by {

View file

@ -1,4 +1,9 @@
import { ApolloCache, DocumentNode, FetchResult } from "@apollo/client"; import {
ApolloCache,
DocumentNode,
FetchResult,
useQuery,
} from "@apollo/client";
import { Modifiers } from "@apollo/client/cache"; import { Modifiers } from "@apollo/client/cache";
import { import {
isField, isField,
@ -1961,43 +1966,6 @@ export const mutateSubmitStashBoxPerformerDraft = (
variables: { input }, variables: { input },
}); });
/// Packages
export const useInstalledScraperPackages = GQL.useInstalledScraperPackagesQuery;
export const useInstalledScraperPackagesStatus =
GQL.useInstalledScraperPackagesStatusQuery;
export const queryAvailableScraperPackages = (source: string) =>
client.query<GQL.AvailableScraperPackagesQuery>({
query: GQL.AvailableScraperPackagesDocument,
variables: {
source,
},
fetchPolicy: "network-only",
});
export const useInstallScraperPackages = GQL.useInstallScraperPackagesMutation;
export const useUpdateScraperPackages = GQL.useUpdateScraperPackagesMutation;
export const useUninstallScraperPackages =
GQL.useUninstallScraperPackagesMutation;
export const useInstalledPluginPackages = GQL.useInstalledPluginPackagesQuery;
export const useInstalledPluginPackagesStatus =
GQL.useInstalledPluginPackagesStatusQuery;
export const queryAvailablePluginPackages = (source: string) =>
client.query<GQL.AvailablePluginPackagesQuery>({
query: GQL.AvailablePluginPackagesDocument,
variables: {
source,
},
fetchPolicy: "network-only",
});
export const useInstallPluginPackages = GQL.useInstallPluginPackagesMutation;
export const useUpdatePluginPackages = GQL.useUpdatePluginPackagesMutation;
export const useUninstallPluginPackages =
GQL.useUninstallPluginPackagesMutation;
/// Configuration /// Configuration
export const useConfiguration = () => GQL.useConfigurationQuery(); export const useConfiguration = () => GQL.useConfigurationQuery();
@ -2043,6 +2011,65 @@ export const useJobsSubscribe = () => GQL.useJobsSubscribeSubscription();
export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription(); export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription();
// all scraper-related queries
export const scraperMutationImpactedQueries = [
GQL.ListMovieScrapersDocument,
GQL.ListPerformerScrapersDocument,
GQL.ListSceneScrapersDocument,
GQL.InstalledScraperPackagesDocument,
GQL.InstalledScraperPackagesStatusDocument,
];
export const mutateReloadScrapers = () =>
client.mutate<GQL.ReloadScrapersMutation>({
mutation: GQL.ReloadScrapersDocument,
update(cache, result) {
if (!result.data?.reloadScrapers) return;
evictQueries(cache, scraperMutationImpactedQueries);
},
});
// all plugin-related queries
export const pluginMutationImpactedQueries = [
GQL.PluginsDocument,
GQL.PluginTasksDocument,
GQL.InstalledPluginPackagesDocument,
GQL.InstalledPluginPackagesStatusDocument,
];
export const mutateReloadPlugins = () =>
client.mutate<GQL.ReloadPluginsMutation>({
mutation: GQL.ReloadPluginsDocument,
update(cache, result) {
if (!result.data?.reloadPlugins) return;
evictQueries(cache, pluginMutationImpactedQueries);
},
});
type BoolMap = { [key: string]: boolean };
export const mutateSetPluginsEnabled = (enabledMap: BoolMap) =>
client.mutate<GQL.SetPluginsEnabledMutation>({
mutation: GQL.SetPluginsEnabledDocument,
variables: { enabledMap },
update(cache, result) {
if (!result.data?.setPluginsEnabled) return;
for (const id in enabledMap) {
cache.modify({
id: cache.identify({ __typename: "Plugin", id }),
fields: {
enabled() {
return enabledMap[id];
},
},
});
}
},
});
function updateConfiguration(cache: ApolloCache<unknown>, result: FetchResult) { function updateConfiguration(cache: ApolloCache<unknown>, result: FetchResult) {
if (!result.data) return; if (!result.data) return;
@ -2051,7 +2078,15 @@ function updateConfiguration(cache: ApolloCache<unknown>, result: FetchResult) {
export const useConfigureGeneral = () => export const useConfigureGeneral = () =>
GQL.useConfigureGeneralMutation({ GQL.useConfigureGeneralMutation({
update: updateConfiguration, update(cache, result) {
if (!result.data?.configureGeneral) return;
evictQueries(cache, [
GQL.ConfigurationDocument,
...scraperMutationImpactedQueries,
...pluginMutationImpactedQueries,
]);
},
}); });
export const useConfigureInterface = () => export const useConfigureInterface = () =>
@ -2097,48 +2132,6 @@ export const useAddTempDLNAIP = () => GQL.useAddTempDlnaipMutation();
export const useRemoveTempDLNAIP = () => GQL.useRemoveTempDlnaipMutation(); export const useRemoveTempDLNAIP = () => GQL.useRemoveTempDlnaipMutation();
export const mutateReloadScrapers = () =>
client.mutate<GQL.ReloadScrapersMutation>({
mutation: GQL.ReloadScrapersDocument,
update(cache, result) {
if (!result.data?.reloadScrapers) return;
evictQueries(cache, [
GQL.ListMovieScrapersDocument,
GQL.ListPerformerScrapersDocument,
GQL.ListSceneScrapersDocument,
]);
},
});
const pluginMutationImpactedQueries = [
GQL.PluginsDocument,
GQL.PluginTasksDocument,
];
export const mutateReloadPlugins = () =>
client.mutate<GQL.ReloadPluginsMutation>({
mutation: GQL.ReloadPluginsDocument,
update(cache, result) {
if (!result.data?.reloadPlugins) return;
evictQueries(cache, pluginMutationImpactedQueries);
},
});
type BoolMap = { [key: string]: boolean };
export const mutateSetPluginsEnabled = (enabledMap: BoolMap) =>
client.mutate<GQL.SetPluginsEnabledMutation>({
mutation: GQL.SetPluginsEnabledDocument,
variables: { enabledMap },
update(cache, result) {
if (!result.data?.setPluginsEnabled) return;
evictQueries(cache, pluginMutationImpactedQueries);
},
});
export const mutateStopJob = (jobID: string) => export const mutateStopJob = (jobID: string) =>
client.mutate<GQL.StopJobMutation>({ client.mutate<GQL.StopJobMutation>({
mutation: GQL.StopJobDocument, mutation: GQL.StopJobDocument,
@ -2172,6 +2165,118 @@ export const mutateMigrate = (input: GQL.MigrateInput) =>
}, },
}); });
/// Packages
// Acts like GQL.useInstalledScraperPackagesStatusQuery if loadUpgrades is true,
// and GQL.useInstalledScraperPackagesQuery if it is false
export const useInstalledScraperPackages = <T extends boolean>(
loadUpgrades: T
) => {
const query = loadUpgrades
? GQL.InstalledScraperPackagesStatusDocument
: GQL.InstalledScraperPackagesDocument;
type TData = T extends true
? GQL.InstalledScraperPackagesStatusQuery
: GQL.InstalledScraperPackagesQuery;
type TVariables = T extends true
? GQL.InstalledScraperPackagesStatusQueryVariables
: GQL.InstalledScraperPackagesQueryVariables;
return useQuery<TData, TVariables>(query);
};
export const queryAvailableScraperPackages = (source: string) =>
client.query<GQL.AvailableScraperPackagesQuery>({
query: GQL.AvailableScraperPackagesDocument,
variables: {
source,
},
fetchPolicy: "network-only",
});
export const mutateInstallScraperPackages = (
packages: GQL.PackageSpecInput[]
) =>
client.mutate<GQL.InstallScraperPackagesMutation>({
mutation: GQL.InstallScraperPackagesDocument,
variables: {
packages,
},
});
export const mutateUpdateScraperPackages = (packages: GQL.PackageSpecInput[]) =>
client.mutate<GQL.UpdateScraperPackagesMutation>({
mutation: GQL.UpdateScraperPackagesDocument,
variables: {
packages,
},
});
export const mutateUninstallScraperPackages = (
packages: GQL.PackageSpecInput[]
) =>
client.mutate<GQL.UninstallScraperPackagesMutation>({
mutation: GQL.UninstallScraperPackagesDocument,
variables: {
packages,
},
});
// Acts like GQL.useInstalledPluginPackagesStatusQuery if loadUpgrades is true,
// and GQL.useInstalledPluginPackagesQuery if it is false
export const useInstalledPluginPackages = <T extends boolean>(
loadUpgrades: T
) => {
const query = loadUpgrades
? GQL.InstalledPluginPackagesStatusDocument
: GQL.InstalledPluginPackagesDocument;
type TData = T extends true
? GQL.InstalledPluginPackagesStatusQuery
: GQL.InstalledPluginPackagesQuery;
type TVariables = T extends true
? GQL.InstalledPluginPackagesStatusQueryVariables
: GQL.InstalledPluginPackagesQueryVariables;
return useQuery<TData, TVariables>(query);
};
export const queryAvailablePluginPackages = (source: string) =>
client.query<GQL.AvailablePluginPackagesQuery>({
query: GQL.AvailablePluginPackagesDocument,
variables: {
source,
},
fetchPolicy: "network-only",
});
export const mutateInstallPluginPackages = (packages: GQL.PackageSpecInput[]) =>
client.mutate<GQL.InstallPluginPackagesMutation>({
mutation: GQL.InstallPluginPackagesDocument,
variables: {
packages,
},
});
export const mutateUpdatePluginPackages = (packages: GQL.PackageSpecInput[]) =>
client.mutate<GQL.UpdatePluginPackagesMutation>({
mutation: GQL.UpdatePluginPackagesDocument,
variables: {
packages,
},
});
export const mutateUninstallPluginPackages = (
packages: GQL.PackageSpecInput[]
) =>
client.mutate<GQL.UninstallPluginPackagesMutation>({
mutation: GQL.UninstallPluginPackagesDocument,
variables: {
packages,
},
});
/// Tasks /// Tasks
export const mutateMetadataScan = (input: GQL.ScanMetadataInput) => export const mutateMetadataScan = (input: GQL.ScanMetadataInput) =>

View file

@ -360,6 +360,10 @@
"number_of_parallel_task_for_scan_generation_desc": "Set to 0 for auto-detection. Warning running more tasks than is required to achieve 100% cpu utilisation will decrease performance and potentially cause other issues.", "number_of_parallel_task_for_scan_generation_desc": "Set to 0 for auto-detection. Warning running more tasks than is required to achieve 100% cpu utilisation will decrease performance and potentially cause other issues.",
"number_of_parallel_task_for_scan_generation_head": "Number of parallel task for scan/generation", "number_of_parallel_task_for_scan_generation_head": "Number of parallel task for scan/generation",
"parallel_scan_head": "Parallel Scan/Generation", "parallel_scan_head": "Parallel Scan/Generation",
"plugins_path": {
"description": "Directory location of plugin configuration files",
"heading": "Plugins Path"
},
"preview_generation": "Preview Generation", "preview_generation": "Preview Generation",
"python_path": { "python_path": {
"description": "Path to the python executable (not just the folder). Used for script scrapers and plugins. If blank, python will be resolved from the environment", "description": "Path to the python executable (not just the folder). Used for script scrapers and plugins. If blank, python will be resolved from the environment",
@ -1098,6 +1102,7 @@
"selected_only": "Selected only", "selected_only": "Selected only",
"show_all": "Show all", "show_all": "Show all",
"uninstall": "Uninstall", "uninstall": "Uninstall",
"unknown": "<unknown>",
"update": "Update", "update": "Update",
"version": "Version" "version": "Version"
}, },