From a1bd7cf817716729625ddfbf791c0f9a4c33c9d6 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Fri, 22 Dec 2023 05:05:53 +0200 Subject: [PATCH] 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 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> --- graphql/documents/data/config.graphql | 1 + graphql/documents/queries/plugins.graphql | 2 +- .../queries/scrapers/scrapers.graphql | 2 +- graphql/schema/types/config.graphql | 4 + graphql/schema/types/package.graphql | 4 +- internal/api/resolver_mutation_configure.go | 20 +- internal/api/resolver_query_configuration.go | 1 + internal/api/resolver_query_package.go | 35 +- internal/manager/config/config.go | 22 +- .../Settings/PluginPackageManager.tsx | 109 +---- .../Settings/ScraperPackageManager.tsx | 115 ++--- .../Settings/SettingsSystemPanel.tsx | 8 + .../Shared/PackageManager/PackageManager.tsx | 461 ++++++++++-------- .../Shared/PackageManager/styles.scss | 53 +- ui/v2.5/src/core/StashService.ts | 267 +++++++--- ui/v2.5/src/locales/en-GB.json | 5 + 16 files changed, 610 insertions(+), 499 deletions(-) diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 310200557c7..32857dd80f1 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -9,6 +9,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { generatedPath metadataPath scrapersPath + pluginsPath cachePath blobsPath blobsStorage diff --git a/graphql/documents/queries/plugins.graphql b/graphql/documents/queries/plugins.graphql index e571bd25a30..ff8b1f908bb 100644 --- a/graphql/documents/queries/plugins.graphql +++ b/graphql/documents/queries/plugins.graphql @@ -55,7 +55,7 @@ query InstalledPluginPackages { query InstalledPluginPackagesStatus { installedPackages(type: Plugin) { ...PackageData - upgrade { + source_package { ...PackageData } } diff --git a/graphql/documents/queries/scrapers/scrapers.graphql b/graphql/documents/queries/scrapers/scrapers.graphql index 3a855c81ff5..366938fd4d5 100644 --- a/graphql/documents/queries/scrapers/scrapers.graphql +++ b/graphql/documents/queries/scrapers/scrapers.graphql @@ -129,7 +129,7 @@ query InstalledScraperPackages { query InstalledScraperPackagesStatus { installedPackages(type: Scraper) { ...PackageData - upgrade { + source_package { ...PackageData } } diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 7cbbe775544..8f439a98823 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -73,6 +73,8 @@ input ConfigGeneralInput { metadataPath: String "Path to scrapers" scrapersPath: String + "Path to plugins" + pluginsPath: String "Path to cache" cachePath: String "Path to blobs - required for filesystem blob storage" @@ -189,6 +191,8 @@ type ConfigGeneralResult { configFilePath: String! "Path to scrapers" scrapersPath: String! + "Path to plugins" + pluginsPath: String! "Path to cache" cachePath: String! "Path to blobs - required for filesystem blob storage" diff --git a/graphql/schema/types/package.graphql b/graphql/schema/types/package.graphql index 798d0954723..2dd5badd889 100644 --- a/graphql/schema/types/package.graphql +++ b/graphql/schema/types/package.graphql @@ -12,8 +12,8 @@ type Package { sourceURL: String! - "The available upgraded version of this package" - upgrade: Package + "The version of this package currently available from the remote source" + source_package: Package metadata: Map! } diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 547f55edc07..e4a01f83072 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -104,6 +104,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen } refreshScraperCache := false + refreshScraperSource := false existingScrapersPath := c.GetScrapersPath() if input.ScrapersPath != nil && existingScrapersPath != *input.ScrapersPath { 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 + refreshScraperSource = true 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() if input.MetadataPath != nil && existingMetadataPath != *input.MetadataPath { 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) } - refreshScraperSource := false if input.ScraperPackageSources != nil { c.Set(config.ScraperPackageSources, input.ScraperPackageSources) refreshScraperSource = true } - refreshPluginSource := false if input.PluginPackageSources != nil { c.Set(config.PluginPackageSources, input.PluginPackageSources) refreshPluginSource = true @@ -367,6 +380,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen if refreshScraperCache { manager.GetInstance().RefreshScraperCache() } + if refreshPluginCache { + manager.GetInstance().RefreshPluginCache() + } if refreshStreamManager { manager.GetInstance().RefreshStreamManager() } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index c32172a1597..ce50f57f461 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -87,6 +87,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult { MetadataPath: config.GetMetadataPath(), ConfigFilePath: config.GetConfigFile(), ScrapersPath: config.GetScrapersPath(), + PluginsPath: config.GetPluginsPath(), CachePath: config.GetCachePath(), BlobsPath: config.GetBlobsPath(), BlobsStorage: config.GetBlobsStorage(), diff --git a/internal/api/resolver_query_package.go b/internal/api/resolver_query_package.go index 0ba3d9e9cf7..021a53190ea 100644 --- a/internal/api/resolver_query_package.go +++ b/internal/api/resolver_query_package.go @@ -98,11 +98,24 @@ func sortedPackageSpecKeys[V any](m map[models.PackageSpecInput]V) []models.Pack } sort.Slice(keys, func(i, j int) bool { - if strings.EqualFold(keys[i].ID, keys[j].ID) { - return keys[i].ID < keys[j].ID + a := keys[i] + 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 @@ -129,9 +142,9 @@ func (r *queryResolver) getInstalledPackagesWithUpgrades(ctx context.Context, pm for _, k := range sortedPackageSpecKeys(packageStatusIndex) { v := packageStatusIndex[k] p := manifestToPackage(*v.Local) - if v.Upgradable() { + if v.Remote != nil { pp := remotePackageToPackage(*v.Remote, allRemoteList) - p.Upgrade = pp + p.SourcePackage = pp } ret[i] = p i++ @@ -146,19 +159,19 @@ func (r *queryResolver) InstalledPackages(ctx context.Context, typeArg PackageTy return nil, err } - installed, err := pm.ListInstalled(ctx) - if err != nil { - return nil, err - } - var ret []*Package - if sliceutil.Contains(graphql.CollectAllFields(ctx), "upgrade") { + if sliceutil.Contains(graphql.CollectAllFields(ctx), "source_package") { ret, err = r.getInstalledPackagesWithUpgrades(ctx, pm) if err != nil { return nil, err } } else { + installed, err := pm.ListInstalled(ctx) + if err != nil { + return nil, err + } + ret = make([]*Package, len(installed)) i := 0 for _, k := range sortedPackageSpecKeys(installed) { diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index d7a512dff6e..e0ce11c297d 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -138,7 +138,7 @@ const ( PluginsSettingPrefix = PluginsSetting + "." DisabledPlugins = "plugins.disabled" - sourceDefaultPath = "stable" + sourceDefaultPath = "community" sourceDefaultName = "Community (stable)" PluginPackageSources = "plugins.package_sources" @@ -1666,16 +1666,16 @@ func (i *Config) setDefaultValues() { i.main.SetDefault(NoProxy, noProxyDefault) // set default package sources - i.main.SetDefault(PluginPackageSources, map[string]string{ - "name": sourceDefaultName, - "url": pluginPackageSourcesDefault, - "local_path": sourceDefaultPath, - }) - i.main.SetDefault(ScraperPackageSources, map[string]string{ - "name": sourceDefaultName, - "url": scraperPackageSourcesDefault, - "local_path": sourceDefaultPath, - }) + i.main.SetDefault(PluginPackageSources, []map[string]string{{ + "name": sourceDefaultName, + "url": pluginPackageSourcesDefault, + "localpath": sourceDefaultPath, + }}) + i.main.SetDefault(ScraperPackageSources, []map[string]string{{ + "name": sourceDefaultName, + "url": scraperPackageSourcesDefault, + "localpath": sourceDefaultPath, + }}) } // setExistingSystemDefaults sets config options that are new and unset in an existing install, diff --git a/ui/v2.5/src/components/Settings/PluginPackageManager.tsx b/ui/v2.5/src/components/Settings/PluginPackageManager.tsx index 0e518e82390..407aa6e5b45 100644 --- a/ui/v2.5/src/components/Settings/PluginPackageManager.tsx +++ b/ui/v2.5/src/components/Settings/PluginPackageManager.tsx @@ -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 { evictQueries, getClient, queryAvailablePluginPackages, - useInstallPluginPackages, useInstalledPluginPackages, - useInstalledPluginPackagesStatus, - useUninstallPluginPackages, - useUpdatePluginPackages, + mutateInstallPluginPackages, + mutateUninstallPluginPackages, + mutateUpdatePluginPackages, + pluginMutationImpactedQueries, } from "src/core/StashService"; import { useMonitorJob } from "src/utils/job"; import { @@ -20,95 +20,59 @@ import { useSettings } from "./context"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { SettingSection } from "./SettingSection"; -const impactedPackageChangeQueries = [ - GQL.PluginsDocument, - GQL.PluginTasksDocument, - GQL.InstalledPluginPackagesDocument, - GQL.InstalledPluginPackagesStatusDocument, -]; - export const InstalledPluginPackages: React.FC = () => { const [loadUpgrades, setLoadUpgrades] = useState(false); const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); - const { data: installedPlugins, refetch: refetchPackages1 } = - useInstalledPluginPackages({ - skip: loadUpgrades, - }); - - const { - data: withStatus, - refetch: refetchPackages2, - loading: statusLoading, - } = useInstalledPluginPackagesStatus({ - skip: !loadUpgrades, - }); - - const [updatePackages] = useUpdatePluginPackages(); - const [uninstallPackages] = useUninstallPluginPackages(); + const { data, previousData, refetch, loading, error } = + useInstalledPluginPackages(loadUpgrades); async function onUpdatePackages(packages: GQL.PackageSpecInput[]) { - const r = await updatePackages({ - variables: { - packages, - }, - }); + const r = await mutateUpdatePluginPackages(packages); setJobID(r.data?.updatePackages); } async function onUninstallPackages(packages: GQL.PackageSpecInput[]) { - const r = await uninstallPackages({ - variables: { - packages, - }, - }); + const r = await mutateUninstallPluginPackages(packages); setJobID(r.data?.uninstallPackages); } - function refetchPackages() { - refetchPackages1(); - refetchPackages2(); - } - function onPackageChanges() { // job is complete, refresh all local data const ac = getClient(); - evictQueries(ac.cache, impactedPackageChangeQueries); + evictQueries(ac.cache, pluginMutationImpactedQueries); } function onCheckForUpdates() { if (!loadUpgrades) { setLoadUpgrades(true); } else { - refetchPackages(); + refetch(); } } - const installedPackages = useMemo(() => { - if (withStatus?.installedPackages) { - return withStatus.installedPackages; - } - - return installedPlugins?.installedPackages ?? []; - }, [installedPlugins, withStatus]); - - const loading = !!job || statusLoading; + // when loadUpgrades changes from false to true, data is set to undefined while the request is loading + // so use previousData as a fallback, which will be the result when loadUpgrades was false, + // to prevent displaying a "No packages found" message + const installedPackages = + data?.installedPackages ?? previousData?.installedPackages ?? []; return (
onUpdatePackages( packages.map((p) => ({ 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} />
@@ -130,18 +94,11 @@ export const InstalledPluginPackages: React.FC = () => { export const AvailablePluginPackages: React.FC = () => { const { general, loading: configLoading, error, saveGeneral } = useSettings(); - const [sources, setSources] = useState(); const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); - const [installPackages] = useInstallPluginPackages(); - async function onInstallPackages(packages: GQL.PackageSpecInput[]) { - const r = await installPackages({ - variables: { - packages, - }, - }); + const r = await mutateInstallPluginPackages(packages); setJobID(r.data?.installPackages); } @@ -149,15 +106,9 @@ export const AvailablePluginPackages: React.FC = () => { function onPackageChanges() { // job is complete, refresh all local data 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 { const { data } = await queryAvailablePluginPackages(source); return data.availablePackages; @@ -167,10 +118,6 @@ export const AvailablePluginPackages: React.FC = () => { saveGeneral({ pluginPackageSources: [...(general.pluginPackageSources ?? []), source], }); - - setSources((prev) => { - return [...(prev ?? []), source]; - }); } function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) { @@ -179,10 +126,6 @@ export const AvailablePluginPackages: React.FC = () => { s.url === existing.url ? changed : s ), }); - - setSources((prev) => { - return prev?.map((s) => (s.url === existing.url ? changed : s)); - }); } function deleteSource(source: GQL.PackageSource) { @@ -191,10 +134,6 @@ export const AvailablePluginPackages: React.FC = () => { (s) => s.url !== source.url ), }); - - setSources((prev) => { - return prev?.filter((s) => s.url !== source.url); - }); } function renderDescription(pkg: RemotePackage) { @@ -208,6 +147,8 @@ export const AvailablePluginPackages: React.FC = () => { const loading = !!job; + const sources = general?.pluginPackageSources ?? []; + return (
@@ -216,7 +157,7 @@ export const AvailablePluginPackages: React.FC = () => { onInstallPackages={onInstallPackages} renderDescription={renderDescription} loadSource={(source) => loadSource(source)} - sources={sources ?? []} + sources={sources} addSource={addSource} editSource={editSource} deleteSource={deleteSource} diff --git a/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx b/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx index f2f8d368750..088b5f8bfa0 100644 --- a/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx +++ b/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx @@ -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 { evictQueries, getClient, queryAvailableScraperPackages, - useInstallScraperPackages, useInstalledScraperPackages, - useInstalledScraperPackagesStatus, - useUninstallScraperPackages, - useUpdateScraperPackages, + mutateUpdateScraperPackages, + mutateUninstallScraperPackages, + mutateInstallScraperPackages, + scraperMutationImpactedQueries, } from "src/core/StashService"; import { useMonitorJob } from "src/utils/job"; import { @@ -20,96 +20,59 @@ import { useSettings } from "./context"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { SettingSection } from "./SettingSection"; -const impactedPackageChangeQueries = [ - GQL.ListPerformerScrapersDocument, - GQL.ListSceneScrapersDocument, - GQL.ListMovieScrapersDocument, - GQL.InstalledScraperPackagesDocument, - GQL.InstalledScraperPackagesStatusDocument, -]; - export const InstalledScraperPackages: React.FC = () => { const [loadUpgrades, setLoadUpgrades] = useState(false); const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); - const { data: installedScrapers, refetch: refetchPackages1 } = - useInstalledScraperPackages({ - skip: loadUpgrades, - }); - - const { - data: withStatus, - refetch: refetchPackages2, - loading: statusLoading, - } = useInstalledScraperPackagesStatus({ - skip: !loadUpgrades, - }); - - const [updatePackages] = useUpdateScraperPackages(); - const [uninstallPackages] = useUninstallScraperPackages(); + const { data, previousData, refetch, loading, error } = + useInstalledScraperPackages(loadUpgrades); async function onUpdatePackages(packages: GQL.PackageSpecInput[]) { - const r = await updatePackages({ - variables: { - packages, - }, - }); + const r = await mutateUpdateScraperPackages(packages); setJobID(r.data?.updatePackages); } async function onUninstallPackages(packages: GQL.PackageSpecInput[]) { - const r = await uninstallPackages({ - variables: { - packages, - }, - }); + const r = await mutateUninstallScraperPackages(packages); setJobID(r.data?.uninstallPackages); } - function refetchPackages() { - refetchPackages1(); - refetchPackages2(); - } - function onPackageChanges() { // job is complete, refresh all local data const ac = getClient(); - evictQueries(ac.cache, impactedPackageChangeQueries); + evictQueries(ac.cache, scraperMutationImpactedQueries); } function onCheckForUpdates() { if (!loadUpgrades) { setLoadUpgrades(true); } else { - refetchPackages(); + refetch(); } } - const installedPackages = useMemo(() => { - if (withStatus?.installedPackages) { - return withStatus.installedPackages; - } - - return installedScrapers?.installedPackages ?? []; - }, [installedScrapers, withStatus]); - - const loading = !!job || statusLoading; + // when loadUpgrades changes from false to true, data is set to undefined while the request is loading + // so use previousData as a fallback, which will be the result when loadUpgrades was false, + // to prevent displaying a "No packages found" message + const installedPackages = + data?.installedPackages ?? previousData?.installedPackages ?? []; return (
onUpdatePackages( packages.map((p) => ({ 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} />
@@ -131,18 +94,11 @@ export const InstalledScraperPackages: React.FC = () => { export const AvailableScraperPackages: React.FC = () => { const { general, loading: configLoading, error, saveGeneral } = useSettings(); - const [sources, setSources] = useState(); const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); - const [installPackages] = useInstallScraperPackages(); - async function onInstallPackages(packages: GQL.PackageSpecInput[]) { - const r = await installPackages({ - variables: { - packages, - }, - }); + const r = await mutateInstallScraperPackages(packages); setJobID(r.data?.installPackages); } @@ -150,15 +106,9 @@ export const AvailableScraperPackages: React.FC = () => { function onPackageChanges() { // job is complete, refresh all local data 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 { const { data } = await queryAvailableScraperPackages(source); return data.availablePackages; @@ -168,10 +118,6 @@ export const AvailableScraperPackages: React.FC = () => { saveGeneral({ scraperPackageSources: [...(general.scraperPackageSources ?? []), source], }); - - setSources((prev) => { - return [...(prev ?? []), source]; - }); } function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) { @@ -180,10 +126,6 @@ export const AvailableScraperPackages: React.FC = () => { s.url === existing.url ? changed : s ), }); - - setSources((prev) => { - return prev?.map((s) => (s.url === existing.url ? changed : s)); - }); } function deleteSource(source: GQL.PackageSource) { @@ -192,10 +134,12 @@ export const AvailableScraperPackages: React.FC = () => { (s) => s.url !== source.url ), }); + } - setSources((prev) => { - return prev?.filter((s) => s.url !== source.url); - }); + function renderDescription(pkg: RemotePackage) { + if (pkg.metadata.description) { + return pkg.metadata.description; + } } if (error) return

{error.message}

; @@ -203,14 +147,17 @@ export const AvailableScraperPackages: React.FC = () => { const loading = !!job; + const sources = general?.scraperPackageSources ?? []; + return (
loadSource(source)} - sources={sources ?? []} + sources={sources} addSource={addSource} editSource={editSource} deleteSource={deleteSource} diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index d41a4a3bc64..a16bffadaf0 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -137,6 +137,14 @@ export const SettingsConfigurationPanel: React.FC = () => { onChange={(v) => saveGeneral({ scrapersPath: v })} /> + saveGeneral({ pluginsPath: v })} + /> + +): 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; const d = new Date(date); @@ -59,14 +73,17 @@ const InstalledPackageRow: React.FC<{ }> = ({ loading, pkg, selected, togglePackage, updatesLoaded }) => { const intl = useIntl(); - function rowClassname() { - if (pkg.upgrade?.version) { - return "package-update-available"; - } - } + const updateAvailable = useMemo(() => { + if (!updatesLoaded) return false; + 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 ( - + {pkg.package_id} - {pkg.version} - {formatDate(intl, pkg.date)} + + {displayVersion(intl, pkg.version)} + + {displayDate(intl, pkg.date)} - {updatesLoaded ? ( + {updatesLoaded && pkg.source_package && ( - {pkg.upgrade?.version} - - {formatDate(intl, pkg.upgrade?.date)} + + {displayVersion(intl, pkg.source_package.version)} + {updateAvailable && } + + + {displayDate(intl, pkg.source_package.date)} - ) : undefined} + )} ); }; @@ -97,6 +119,7 @@ const InstalledPackageRow: React.FC<{ const InstalledPackagesList: React.FC<{ filter: string; loading?: boolean; + error?: string; updatesLoaded: boolean; packages: InstalledPackage[]; checkedPackages: InstalledPackage[]; @@ -108,12 +131,13 @@ const InstalledPackagesList: React.FC<{ setCheckedPackages, updatesLoaded, loading, + error, }) => { const checkedMap = useMemo(() => { const map: Record = {}; - checkedPackages.forEach((pkg) => { - map[`${pkg.sourceURL}-${pkg.package_id}`] = true; - }); + for (const pkg of checkedPackages) { + map[packageKey(pkg)] = true; + } return map; }, [checkedPackages]); @@ -134,19 +158,54 @@ const InstalledPackagesList: React.FC<{ setCheckedPackages((prev) => { if (prev.includes(pkg)) { - return prev.filter((n) => n.package_id !== pkg.package_id); + return prev.filter((n) => packageKey(n) !== packageKey(pkg)); } else { - return prev.concat(pkg); + return [...prev, pkg]; } }); } + function renderBody() { + if (error) { + return ( + + + + + {error} + + + ); + } + + if (filteredPackages.length === 0) { + return ( + + + + + + ); + } + + return filteredPackages.map((pkg) => ( + togglePackage(pkg)} + updatesLoaded={updatesLoaded} + /> + )); + } + return (
- - - {filteredPackages.length === 0 ? ( - - - - ) : ( - filteredPackages.map((pkg) => ( - togglePackage(pkg)} - updatesLoaded={updatesLoaded} - /> - )) - )} - + {renderBody()}
+
- -
); @@ -213,42 +251,40 @@ const InstalledPackagesToolbar: React.FC<{ const intl = useIntl(); return (
-
- setFilter(v)} - /> -
-
- - - -
+ setFilter(v)} + /> +
+ + +
); }; export const InstalledPackages: React.FC<{ loading?: boolean; + error?: string; packages: InstalledPackage[]; updatesLoaded: boolean; onCheckForUpdates: () => void; @@ -261,6 +297,7 @@ export const InstalledPackages: React.FC<{ onUpdatePackages, onUninstallPackages, loading, + error, }) => { const [checkedPackages, setCheckedPackages] = useState( [] @@ -275,7 +312,7 @@ export const InstalledPackages: React.FC<{ useEffect(() => { setCheckedPackages((prev) => { 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) { return newVal; @@ -316,6 +353,7 @@ export const InstalledPackages: React.FC<{ -
- setFilter(v)} - /> - {hasSelectedPackages && ( - - )} -
-
+ setFilter(v)} + /> + {hasSelectedPackages && ( -
+ )} +
+
); }; @@ -552,7 +587,7 @@ const AvailablePackageRow: React.FC<{ } return ( - + {pkg.package_id} - {pkg.version} - {formatDate(intl, pkg.date)} + + {displayVersion(intl, pkg.version)} + + {displayDate(intl, pkg.date)} {renderRequiredBy()} @@ -655,36 +692,62 @@ const SourcePackagesList: React.FC<{ } function toggleSourceOpen() { - if (packages === undefined) { - loadPackages(); + if (sourceOpen) { + setLoadError(undefined); + setSourceOpen(false); + } else { + if (packages === undefined) { + loadPackages(); + } + setSourceOpen(true); } - - setSourceOpen((prev) => !prev); } - function renderCollapseButton() { - return ( - - ); - } + function renderContents() { + if (loading) { + return ( + + + + + + + ); + } + + if (loadError) { + return ( + + + + + {loadError} + + + + ); + } + + if (!sourceOpen) { + return null; + } - const children = useMemo(() => { function getRequiredPackages(pkg: 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); if (found && !ret.includes(found)) { ret.push(found); ret.push(...getRequiredPackages(found)); } - }); + } return ret; } @@ -698,10 +761,7 @@ const SourcePackagesList: React.FC<{ return prev.filter((n) => n.package_id !== pkg.package_id); } else { // also include required packages - const toAdd = [pkg]; - toAdd.push(...getRequiredPackages(pkg)); - - return prev.concat(...toAdd); + return [...prev, pkg, ...getRequiredPackages(pkg)]; } }); } @@ -711,27 +771,19 @@ const SourcePackagesList: React.FC<{ key={pkg.package_id} disabled={disabled} pkg={pkg} - requiredBy={selectedPackages.filter((p) => { - return p.requires.find((r) => r.package_id === pkg.package_id); - })} + requiredBy={selectedPackages.filter((p) => + p.requires.some((r) => r.package_id === pkg.package_id) + )} selected={checkedMap[pkg.package_id] ?? false} togglePackage={() => togglePackage(pkg)} renderDescription={renderDescription} /> )); - }, [ - filteredPackages, - disabled, - checkedMap, - selectedPackages, - setSelectedPackages, - packages, - renderDescription, - ]); + } return ( <> - + {packages !== undefined ? ( ) : undefined} - {renderCollapseButton()} - toggleSourceOpen()}> + + + + toggleSourceOpen()} + > {source.name ?? source.url} @@ -764,32 +828,7 @@ const SourcePackagesList: React.FC<{ - {loading ? ( - - - - - - - ) : undefined} - {loadError ? ( - - - - - {loadError} - - - - ) : undefined} - {sourceOpen && !loading && children} + {renderContents()} ); }; @@ -847,6 +886,58 @@ const AvailablePackagesList: React.FC<{ }); } + function renderBody() { + if (sources.length === 0) { + return ( + + + +
+ + + + ); + } + + return ( + <> + {sources.map((src) => ( + loadSource(src.url)} + selectedOnly={selectedOnly} + selectedPackages={selectedPackages[src.url] ?? []} + setSelectedPackages={(v) => setSelectedSourcePackages(src, v)} + editSource={() => setEditingSource(src)} + deleteSource={() => setDeletingSource(src)} + /> + ))} + + + + + + + + ); + } + return ( <> - - + + @@ -893,49 +984,7 @@ const AvailablePackagesList: React.FC<{ - - {sources.length === 0 ? ( - - - -
- - - - ) : ( - sources.map((src) => ( - loadSource(src.url)} - selectedOnly={selectedOnly} - selectedPackages={selectedPackages[src.url] ?? []} - setSelectedPackages={(v) => setSelectedSourcePackages(src, v)} - editSource={() => setEditingSource(src)} - deleteSource={() => setDeletingSource(src)} - /> - )) - )} - {sources.length > 0 ? ( - - - setAddingSource(true)}> - - - - ) : undefined} - + {renderBody()}
diff --git a/ui/v2.5/src/components/Shared/PackageManager/styles.scss b/ui/v2.5/src/components/Shared/PackageManager/styles.scss index a9c930f91ad..3613e5046c9 100644 --- a/ui/v2.5/src/components/Shared/PackageManager/styles.scss +++ b/ui/v2.5/src/components/Shared/PackageManager/styles.scss @@ -4,9 +4,19 @@ .package-source { font-weight: bold; + .source-collapse { + padding-left: 0; + padding-right: 0; + + .btn { + color: $text-color; + } + } + .source-controls { align-items: center; display: flex; + gap: 0.5rem; justify-content: end; } } @@ -16,10 +26,6 @@ cursor: pointer; } - .package-collapse-button { - color: $text-color; - } - .package-manager-table-container { max-height: 300px; overflow-y: auto; @@ -31,39 +37,54 @@ top: 0; z-index: 1; - .button-cell { + .check-cell { width: 40px; } + + .collapse-cell { + width: 30px; + } } table td { vertical-align: middle; } + .package-name, + .package-id, .package-version, .package-date, - .package-name, - .package-id { + .package-latest-version, + .package-latest-date { display: block; } + .package-id, .package-date, - .package-id { + .package-latest-date { color: $muted-gray; font-size: 0.8rem; } - .package-manager-toolbar { - display: flex; - justify-content: space-between; + .package-id, + .package-version, + .package-date, + .package-latest-version, + .package-latest-date { + white-space: nowrap; + } - div { - display: flex; + .package-update-available { + .package-latest-version, + .package-latest-date { + font-weight: 700; } + } - .btn { - margin-left: 0.5em; - } + .package-manager-toolbar { + display: flex; + gap: 0.5rem; + padding-bottom: 0.25rem; } .package-required-by { diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index d32982f492e..15ee2868273 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -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 { isField, @@ -1961,43 +1966,6 @@ export const mutateSubmitStashBoxPerformerDraft = ( variables: { input }, }); -/// Packages -export const useInstalledScraperPackages = GQL.useInstalledScraperPackagesQuery; -export const useInstalledScraperPackagesStatus = - GQL.useInstalledScraperPackagesStatusQuery; - -export const queryAvailableScraperPackages = (source: string) => - client.query({ - 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({ - query: GQL.AvailablePluginPackagesDocument, - variables: { - source, - }, - fetchPolicy: "network-only", - }); - -export const useInstallPluginPackages = GQL.useInstallPluginPackagesMutation; -export const useUpdatePluginPackages = GQL.useUpdatePluginPackagesMutation; -export const useUninstallPluginPackages = - GQL.useUninstallPluginPackagesMutation; - /// Configuration export const useConfiguration = () => GQL.useConfigurationQuery(); @@ -2043,6 +2011,65 @@ export const useJobsSubscribe = () => GQL.useJobsSubscribeSubscription(); 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({ + 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({ + 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({ + 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, result: FetchResult) { if (!result.data) return; @@ -2051,7 +2078,15 @@ function updateConfiguration(cache: ApolloCache, result: FetchResult) { export const useConfigureGeneral = () => GQL.useConfigureGeneralMutation({ - update: updateConfiguration, + update(cache, result) { + if (!result.data?.configureGeneral) return; + + evictQueries(cache, [ + GQL.ConfigurationDocument, + ...scraperMutationImpactedQueries, + ...pluginMutationImpactedQueries, + ]); + }, }); export const useConfigureInterface = () => @@ -2097,48 +2132,6 @@ export const useAddTempDLNAIP = () => GQL.useAddTempDlnaipMutation(); export const useRemoveTempDLNAIP = () => GQL.useRemoveTempDlnaipMutation(); -export const mutateReloadScrapers = () => - client.mutate({ - 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({ - 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({ - mutation: GQL.SetPluginsEnabledDocument, - variables: { enabledMap }, - update(cache, result) { - if (!result.data?.setPluginsEnabled) return; - - evictQueries(cache, pluginMutationImpactedQueries); - }, - }); - export const mutateStopJob = (jobID: string) => client.mutate({ 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 = ( + 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(query); +}; + +export const queryAvailableScraperPackages = (source: string) => + client.query({ + query: GQL.AvailableScraperPackagesDocument, + variables: { + source, + }, + fetchPolicy: "network-only", + }); + +export const mutateInstallScraperPackages = ( + packages: GQL.PackageSpecInput[] +) => + client.mutate({ + mutation: GQL.InstallScraperPackagesDocument, + variables: { + packages, + }, + }); + +export const mutateUpdateScraperPackages = (packages: GQL.PackageSpecInput[]) => + client.mutate({ + mutation: GQL.UpdateScraperPackagesDocument, + variables: { + packages, + }, + }); + +export const mutateUninstallScraperPackages = ( + packages: GQL.PackageSpecInput[] +) => + client.mutate({ + mutation: GQL.UninstallScraperPackagesDocument, + variables: { + packages, + }, + }); + +// Acts like GQL.useInstalledPluginPackagesStatusQuery if loadUpgrades is true, +// and GQL.useInstalledPluginPackagesQuery if it is false +export const useInstalledPluginPackages = ( + 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(query); +}; + +export const queryAvailablePluginPackages = (source: string) => + client.query({ + query: GQL.AvailablePluginPackagesDocument, + variables: { + source, + }, + fetchPolicy: "network-only", + }); + +export const mutateInstallPluginPackages = (packages: GQL.PackageSpecInput[]) => + client.mutate({ + mutation: GQL.InstallPluginPackagesDocument, + variables: { + packages, + }, + }); + +export const mutateUpdatePluginPackages = (packages: GQL.PackageSpecInput[]) => + client.mutate({ + mutation: GQL.UpdatePluginPackagesDocument, + variables: { + packages, + }, + }); + +export const mutateUninstallPluginPackages = ( + packages: GQL.PackageSpecInput[] +) => + client.mutate({ + mutation: GQL.UninstallPluginPackagesDocument, + variables: { + packages, + }, + }); + /// Tasks export const mutateMetadataScan = (input: GQL.ScanMetadataInput) => diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 262a8353fea..8332ed3f7c7 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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_head": "Number of parallel task for scan/generation", "parallel_scan_head": "Parallel Scan/Generation", + "plugins_path": { + "description": "Directory location of plugin configuration files", + "heading": "Plugins Path" + }, "preview_generation": "Preview Generation", "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", @@ -1098,6 +1102,7 @@ "selected_only": "Selected only", "show_all": "Show all", "uninstall": "Uninstall", + "unknown": "", "update": "Update", "version": "Version" },