diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 0178c2597..e4974e4a1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties; @Slf4j public class EndpointConfiguration { + public enum DisableReason { + CONFIG, + DEPENDENCY, + UNKNOWN + } + + public static class EndpointAvailability { + private final boolean enabled; + private final DisableReason reason; + + public EndpointAvailability(boolean enabled, DisableReason reason) { + this.enabled = enabled; + this.reason = reason; + } + + public boolean isEnabled() { + return enabled; + } + + public DisableReason getReason() { + return reason; + } + } + private static final String REMOVE_BLANKS = "remove-blanks"; private final ApplicationProperties applicationProperties; @Getter private Map endpointStatuses = new ConcurrentHashMap<>(); private Map> endpointGroups = new ConcurrentHashMap<>(); private Set disabledGroups = new HashSet<>(); + private Map endpointDisableReasons = new ConcurrentHashMap<>(); + private Map groupDisableReasons = new ConcurrentHashMap<>(); private Map> endpointAlternatives = new ConcurrentHashMap<>(); private final boolean runningProOrHigher; @@ -35,16 +61,31 @@ public class EndpointConfiguration { processEnvironmentConfigs(); } + private String normalizeEndpoint(String endpoint) { + if (endpoint == null) { + return null; + } + return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint; + } + public void enableEndpoint(String endpoint) { - endpointStatuses.put(endpoint, true); - log.debug("Enabled endpoint: {}", endpoint); + String normalized = normalizeEndpoint(endpoint); + endpointStatuses.put(normalized, true); + endpointDisableReasons.remove(normalized); + log.debug("Enabled endpoint: {}", normalized); } public void disableEndpoint(String endpoint) { - if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) { - log.debug("Disabling endpoint: {}", endpoint); + disableEndpoint(endpoint, DisableReason.CONFIG); + } + + public void disableEndpoint(String endpoint, DisableReason reason) { + String normalized = normalizeEndpoint(endpoint); + if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) { + log.debug("Disabling endpoint: {}", normalized); } - endpointStatuses.put(endpoint, false); + endpointStatuses.put(normalized, false); + endpointDisableReasons.put(normalized, reason); } public boolean isEndpointEnabled(String endpoint) { @@ -150,6 +191,10 @@ public class EndpointConfiguration { } public void disableGroup(String group) { + disableGroup(group, DisableReason.CONFIG); + } + + public void disableGroup(String group, DisableReason reason) { if (disabledGroups.add(group)) { if (isToolGroup(group)) { log.debug( @@ -161,11 +206,12 @@ public class EndpointConfiguration { group); } } + groupDisableReasons.put(group, reason); // Only cascade to endpoints for *functional* groups if (!isToolGroup(group)) { Set endpoints = endpointGroups.get(group); if (endpoints != null) { - endpoints.forEach(this::disableEndpoint); + endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason)); } } } @@ -174,12 +220,39 @@ public class EndpointConfiguration { if (disabledGroups.remove(group)) { log.debug("Enabling group: {}", group); } + groupDisableReasons.remove(group); Set endpoints = endpointGroups.get(group); if (endpoints != null) { endpoints.forEach(this::enableEndpoint); } } + public EndpointAvailability getEndpointAvailability(String endpoint) { + boolean enabled = isEndpointEnabled(endpoint); + DisableReason reason = enabled ? null : determineDisableReason(endpoint); + return new EndpointAvailability(enabled, reason); + } + + private DisableReason determineDisableReason(String endpoint) { + String normalized = normalizeEndpoint(endpoint); + if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) { + return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG); + } + + for (Map.Entry> entry : endpointGroups.entrySet()) { + String group = entry.getKey(); + Set endpoints = entry.getValue(); + if (!disabledGroups.contains(group) || endpoints == null) { + continue; + } + if (endpoints.contains(normalized)) { + return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG); + } + } + + return DisableReason.UNKNOWN; + } + public Set getDisabledGroups() { return new HashSet<>(disabledGroups); } @@ -261,6 +334,8 @@ public class EndpointConfiguration { addEndpointToGroup("Convert", "pdf-to-csv"); addEndpointToGroup("Convert", "pdf-to-markdown"); addEndpointToGroup("Convert", "eml-to-pdf"); + addEndpointToGroup("Convert", "cbz-to-pdf"); + addEndpointToGroup("Convert", "pdf-to-cbz"); // Adding endpoints to "Security" group addEndpointToGroup("Security", "add-password"); @@ -394,6 +469,8 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "pdf-to-markdown"); addEndpointToGroup("Java", "add-attachments"); addEndpointToGroup("Java", "compress-pdf"); + addEndpointToGroup("Java", "cbz-to-pdf"); + addEndpointToGroup("Java", "pdf-to-cbz"); addEndpointToGroup("rar", "pdf-to-cbr"); // Javascript diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index fd3ab640d..8c3a046f4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.EndpointConfiguration.DisableReason; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.util.RegexPatternUtils; @@ -97,7 +98,7 @@ public class ExternalAppDepConfig { if (affectedGroups != null) { for (String group : affectedGroups) { List affectedFeatures = getAffectedFeatures(group); - endpointConfiguration.disableGroup(group); + endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY); log.warn( "Missing dependency: {} - Disabling group: {} (Affected features: {})", command, @@ -127,8 +128,8 @@ public class ExternalAppDepConfig { if (!pythonAvailable) { List pythonFeatures = getAffectedFeatures("Python"); List openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("Python"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}", String.join(", ", pythonFeatures), @@ -146,14 +147,14 @@ public class ExternalAppDepConfig { int exitCode = process.waitFor(); if (exitCode != 0) { List openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "OpenCV not available in Python - Disabling OpenCV features: {}", String.join(", ", openCVFeatures)); } } catch (Exception e) { List openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "Error checking OpenCV: {} - Disabling OpenCV features: {}", e.getMessage(), diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index a4a169cd9..750bfab48 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -1,6 +1,7 @@ package stirling.software.SPDF.controller.api.misc; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.context.ApplicationContext; @@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability; import stirling.software.SPDF.config.InitialSetup; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; @@ -200,4 +205,19 @@ public class ConfigController { } return ResponseEntity.ok(result); } + + @GetMapping("/endpoints-availability") + public ResponseEntity> getEndpointAvailability( + @RequestParam(name = "endpoints") + @Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints") + List<@NotBlank String> endpoints) { + Map result = new HashMap<>(); + for (String endpoint : endpoints) { + String trimmedEndpoint = endpoint.trim(); + result.put( + trimmedEndpoint, + endpointConfiguration.getEndpointAvailability(trimmedEndpoint)); + } + return ResponseEntity.ok(result); + } } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index dcaaed974..50e047698 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -17,6 +17,8 @@ "comingSoon": "Coming soon:", "favorite": "Add to favourites", "favorites": "Favourites", + "unavailable": "Disabled by server administrator:", + "unavailableDependency": "Unavailable - required tool missing on server:", "heading": "All tools (fullscreen view)", "noResults": "Try adjusting your search or toggle descriptions to find what you need.", "recommended": "Recommended", @@ -5721,7 +5723,11 @@ "latestVersion": "Latest Version", "checkForUpdates": "Check for Updates", "viewDetails": "View Details" - } + }, + "hideUnavailableTools": "Hide unavailable tools", + "hideUnavailableToolsDescription": "Remove tools that have been disabled by your server instead of showing them greyed out.", + "hideUnavailableConversions": "Hide unavailable conversions", + "hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out." }, "hotkeys": { "errorConflict": "Shortcut already used by {{tool}}.", diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index e80a1fb07..e8f37a286 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -53,11 +53,17 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => }; const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo); - if (summary) { + if (summary && summary.latest_version) { const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0; if (isNewerVersion) { setUpdateSummary(summary); + } else { + // Clear any existing update summary if user is on latest version + setUpdateSummary(null); } + } else { + // No update available (latest_version is null) - clear any existing update summary + setUpdateSummary(null); } setCheckingUpdate(false); }; @@ -128,83 +134,6 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => )} - - -
-
- - {t('settings.general.defaultToolPickerMode', 'Default tool picker mode')} - - - {t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')} - -
- updatePreference('defaultToolPanelMode', val as ToolPanelMode)} - data={[ - { label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' }, - { label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' }, - ]} - /> -
- -
-
- - {t('settings.general.autoUnzip', 'Auto-unzip API responses')} - - - {t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')} - -
- updatePreference('autoUnzip', event.currentTarget.checked)} - /> -
-
- - -
-
- - {t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')} - - - {t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')} - -
- { - const numValue = Number(fileLimitInput); - const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue; - setFileLimitInput(finalValue); - updatePreference('autoUnzipFileLimit', finalValue); - }} - min={1} - max={100} - step={1} - disabled={!preferences.autoUnzip} - style={{ width: 90 }} - /> -
-
-
-
- {/* Update Check Section */} {config?.appVersion && ( @@ -292,6 +221,111 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => )} + + +
+
+ + {t('settings.general.defaultToolPickerMode', 'Default tool picker mode')} + + + {t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')} + +
+ updatePreference('defaultToolPanelMode', val as ToolPanelMode)} + data={[ + { label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' }, + { label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' }, + ]} + /> +
+
+
+ + {t('settings.general.hideUnavailableTools', 'Hide unavailable tools')} + + + {t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')} + +
+ updatePreference('hideUnavailableTools', event.currentTarget.checked)} + /> +
+
+
+ + {t('settings.general.hideUnavailableConversions', 'Hide unavailable conversions')} + + + {t('settings.general.hideUnavailableConversionsDescription', 'Remove disabled conversion options in the Convert tool instead of showing them greyed out.')} + +
+ updatePreference('hideUnavailableConversions', event.currentTarget.checked)} + /> +
+ +
+
+ + {t('settings.general.autoUnzip', 'Auto-unzip API responses')} + + + {t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')} + +
+ updatePreference('autoUnzip', event.currentTarget.checked)} + /> +
+
+ + +
+
+ + {t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')} + + + {t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')} + +
+ { + const numValue = Number(fileLimitInput); + const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue; + setFileLimitInput(finalValue); + updatePreference('autoUnzipFileLimit', finalValue); + }} + min={1} + max={100} + step={1} + disabled={!preferences.autoUnzip} + style={{ width: 90 }} + /> +
+
+
+
+ {/* Update Modal */} {updateSummary && config?.appVersion && config?.machineType && ( getConversionEndpoints(EXTENSION_TO_ENDPOINT), []); + const allEndpoints = useMemo(() => { + const endpoints = getConversionEndpoints(EXTENSION_TO_ENDPOINT); + return endpoints; + }, []); const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints); @@ -56,7 +61,8 @@ const ConvertSettings = ({ const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt]; if (!endpointKey) return false; - return endpointStatus[endpointKey] === true; + const isAvailable = endpointStatus[endpointKey] === true; + return isAvailable; }; // Enhanced FROM options with endpoint availability @@ -74,6 +80,12 @@ const ConvertSettings = ({ }; }); + // Filter out unavailable source formats if preference is enabled + let filteredOptions = baseOptions; + if (preferences.hideUnavailableConversions) { + filteredOptions = baseOptions.filter(opt => opt.enabled !== false); + } + // Add dynamic format option if current selection is a file- format if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) { const extension = parameters.fromExtension.replace('file-', ''); @@ -85,22 +97,32 @@ const ConvertSettings = ({ }; // Add the dynamic option at the beginning - return [dynamicOption, ...baseOptions]; + return [dynamicOption, ...filteredOptions]; } - return baseOptions; - }, [parameters.fromExtension, endpointStatus]); + return filteredOptions; + }, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]); // Enhanced TO options with endpoint availability const enhancedToOptions = useMemo(() => { if (!parameters.fromExtension) return []; const availableOptions = getAvailableToExtensions(parameters.fromExtension) || []; - return availableOptions.map(option => ({ - ...option, - enabled: isConversionAvailable(parameters.fromExtension, option.value) - })); - }, [parameters.fromExtension, endpointStatus]); + const enhanced = availableOptions.map(option => { + const enabled = isConversionAvailable(parameters.fromExtension, option.value); + return { + ...option, + enabled + }; + }); + + // Filter out unavailable conversions if preference is enabled + if (preferences.hideUnavailableConversions) { + return enhanced.filter(opt => opt.enabled !== false); + } + + return enhanced; + }, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]); const resetParametersToDefaults = () => { onParameterChange('imageOptions', { diff --git a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx index 648f79267..4a9b30869 100644 --- a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx @@ -5,7 +5,7 @@ import { Tooltip } from '@app/components/shared/Tooltip'; import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay'; import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar'; import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy'; -import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared'; +import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared'; interface CompactToolItemProps { id: string; @@ -17,7 +17,7 @@ interface CompactToolItemProps { const CompactToolItem: React.FC = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => { const { t } = useTranslation(); - const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool); + const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool); const categoryColor = getSubcategoryColor(tool.subcategoryId); const iconBg = getIconBackground(categoryColor, false); const iconClasses = 'tool-panel__fullscreen-list-icon'; @@ -73,9 +73,12 @@ const CompactToolItem: React.FC = ({ id, tool, isSelected, ); + const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason); + const disabledMessage = t(disabledKey, disabledFallback); + const tooltipContent = disabled ? ( - {t('toolPanel.fullscreen.comingSoon', 'Coming soon:')} {tool.description} + {disabledMessage} {tool.description} ) : (
diff --git a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx index 2ebf93b35..8352a3c4e 100644 --- a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay'; import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar'; import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy'; -import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared'; +import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared'; interface DetailedToolItemProps { id: string; @@ -15,7 +15,7 @@ interface DetailedToolItemProps { const DetailedToolItem: React.FC = ({ id, tool, isSelected, onClick }) => { const { t } = useTranslation(); - const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool); + const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool); const categoryColor = getSubcategoryColor(tool.subcategoryId); const iconBg = getIconBackground(categoryColor, true); @@ -34,6 +34,9 @@ const DetailedToolItem: React.FC = ({ id, tool, isSelecte iconNode = tool.icon; } + const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason); + const disabledMessage = t(disabledKey, disabledFallback); + return (