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 0178c25971..e4974e4a16 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 fd3ab640d9..8c3a046f49 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 a4a169cd91..750bfab48f 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/package-lock.json b/frontend/package-lock.json index 86f3f9a363..296fa51a21 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-bookmark": "^1.4.1", "@embedpdf/plugin-export": "^1.4.1", "@embedpdf/plugin-history": "^1.4.1", "@embedpdf/plugin-interaction-manager": "^1.4.1", @@ -633,6 +634,22 @@ "vue": ">=3.2.0" } }, + "node_modules/@embedpdf/plugin-bookmark": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz", + "integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "1.4.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.4.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, "node_modules/@embedpdf/plugin-export": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index dbfd9d3479..ba96f1d04d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-bookmark": "^1.4.1", "@embedpdf/plugin-export": "^1.4.1", "@embedpdf/plugin-history": "^1.4.1", "@embedpdf/plugin-interaction-manager": "^1.4.1", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2ef58dcccf..608190b941 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", @@ -5730,7 +5732,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 e80a1fb07d..e8f37a2866 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 648f79267e..4a9b30869c 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 2ebf93b357..8352a3c4e3 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 ( + + )} + + {bookmarkSupport && documentCacheKey && isLocalLoading && ( + + + + Loading bookmarks... + + + )} + + {showEmptyState && ( +
+ + No bookmarks in this document + +
+ )} + + {showBookmarkList && ( +
+ {renderBookmarks(filteredBookmarks)} +
+ )} + + {showSearchEmpty && ( +
+ + No bookmarks match your search + +
+ )} + + + + ); +}; diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index e9695712d4..87ad055688 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -8,6 +8,7 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF'; import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar'; import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar'; +import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar'; import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext'; import { useSignature } from '@app/contexts/SignatureContext'; import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers'; @@ -38,7 +39,19 @@ const EmbedPdfViewerContent = ({ const pdfContainerRef = useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); + const { + isThumbnailSidebarVisible, + toggleThumbnailSidebar, + isBookmarkSidebarVisible, + zoomActions, + panActions: _panActions, + rotationActions: _rotationActions, + getScrollState, + getRotationState, + isAnnotationMode, + isAnnotationsVisible, + exportActions, + } = useViewer(); // Register viewer right-rail buttons useViewerRightRailButtons(); @@ -129,6 +142,42 @@ const EmbedPdfViewerContent = ({ } }, [previewFile, fileWithUrl]); + const bookmarkCacheKey = React.useMemo(() => { + if (currentFile && isStirlingFile(currentFile)) { + return currentFile.fileId; + } + + if (previewFile) { + const uniquePreviewId = `${previewFile.name}-${previewFile.size}-${previewFile.lastModified ?? 'na'}`; + return `preview-${uniquePreviewId}`; + } + + if (effectiveFile?.url) { + return effectiveFile.url; + } + + if (effectiveFile?.file instanceof File) { + const fileObj = effectiveFile.file; + return `file-${fileObj.name}-${fileObj.size}-${fileObj.lastModified ?? 'na'}`; + } + + return undefined; + }, [currentFile, effectiveFile, previewFile]); + + // Generate cache keys for all active files to enable preloading + const allBookmarkCacheKeys = React.useMemo(() => { + if (previewFile) { + return [bookmarkCacheKey].filter(Boolean) as string[]; + } + + return activeFiles.map(file => { + if (isStirlingFile(file)) { + return file.fileId; + } + return undefined; + }).filter(Boolean) as string[]; + }, [activeFiles, previewFile, bookmarkCacheKey]); + useWheelZoom({ ref: viewerRef, onZoomIn: zoomActions.zoomIn, @@ -220,6 +269,10 @@ const EmbedPdfViewerContent = ({ } }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); + const sidebarWidthRem = 15; + const totalRightMargin = + (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0); + return ( + {/* Navigation Warning Modal */} {!previewFile && ( diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index f20b68b9a9..c08eabcbb7 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -19,6 +19,7 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react'; import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react'; import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark'; // Import annotation plugins import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; @@ -39,6 +40,7 @@ import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge'; import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge'; import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes'; import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge'; +import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -138,6 +140,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur // Register thumbnail plugin for page thumbnails createPluginRegistration(ThumbnailPluginPackage), + // Register bookmark plugin for PDF outline support + createPluginRegistration(BookmarkPluginPackage), + // Register rotate plugin createPluginRegistration(RotatePluginPackage), @@ -272,6 +277,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur {enableAnnotations && } {enableAnnotations && } + (() => { return [ @@ -111,6 +112,17 @@ export function useViewerRightRailButtons() { viewer.toggleThumbnailSidebar(); } }, + { + id: 'viewer-toggle-bookmarks', + icon: , + tooltip: bookmarkLabel, + ariaLabel: bookmarkLabel, + section: 'top' as const, + order: 55, + onClick: () => { + viewer.toggleBookmarkSidebar(); + } + }, { id: 'viewer-annotation-controls', section: 'top' as const, @@ -120,7 +132,7 @@ export function useViewerRightRailButtons() { ) } ]; - }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]); + }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel]); useRightRailButtons(viewerButtons); } diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index 7c657506ea..f60c28785d 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -4,7 +4,7 @@ */ import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react'; -import { useToolManagement } from '@app/hooks/useToolManagement'; +import { useToolManagement, type ToolAvailabilityMap } from '@app/hooks/useToolManagement'; import { PageEditorFunctions } from '@app/types/pageEditor'; import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy'; import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; @@ -44,6 +44,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { selectedTool: ToolRegistryEntry | null; toolRegistry: Partial; getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null; + toolAvailability: ToolAvailabilityMap; // UI Actions setSidebarsVisible: (visible: boolean) => void; @@ -112,7 +113,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const navigationState = useNavigationState(); // Tool management hook - const { toolRegistry, getSelectedTool } = useToolManagement(); + const { toolRegistry, getSelectedTool, toolAvailability } = useToolManagement(); const { allTools } = useToolRegistry(); // Tool history hook @@ -258,6 +259,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // Workflow actions (compound actions that coordinate multiple state changes) const handleToolSelect = useCallback((toolId: ToolId) => { + const availabilityInfo = toolAvailability[toolId]; + const isExplicitlyDisabled = availabilityInfo ? availabilityInfo.available === false : false; + if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) { + return; + } // If we're currently on a custom workbench (e.g., Validate Signature report), // selecting any tool should take the user back to the default file manager view. const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench); @@ -299,7 +305,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { setSearchQuery(''); setLeftPanelView('toolContent'); setReaderMode(false); // Disable read mode when selecting tools - }, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]); + }, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]); const handleBackToTools = useCallback(() => { setLeftPanelView('toolPicker'); @@ -354,6 +360,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { toolResetFunctions, registerToolReset, resetTool, + toolAvailability, // Workflow Actions handleToolSelect, @@ -381,6 +388,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { selectedTool, toolRegistry, getSelectedTool, + toolAvailability, setSidebarsVisible, setLeftPanelView, setReaderMode, diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index fc0f1ab70d..fc1eaf3464 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -17,6 +17,7 @@ import { RotationActions, SearchActions, ExportActions, + BookmarkActions, } from '@app/contexts/viewer/viewerActions'; import { BridgeRef, @@ -35,6 +36,7 @@ import { SearchState, ExportState, ThumbnailAPIWrapper, + BookmarkState, } from '@app/contexts/viewer/viewerBridges'; import { SpreadMode } from '@embedpdf/plugin-spread/react'; @@ -74,6 +76,8 @@ interface ViewerContextType { // UI state managed by this context isThumbnailSidebarVisible: boolean; toggleThumbnailSidebar: () => void; + isBookmarkSidebarVisible: boolean; + toggleBookmarkSidebar: () => void; // Annotation visibility toggle isAnnotationsVisible: boolean; @@ -98,6 +102,8 @@ interface ViewerContextType { getSearchState: () => SearchState; getThumbnailAPI: () => ThumbnailAPIWrapper | null; getExportState: () => ExportState; + getBookmarkState: () => BookmarkState; + hasBookmarkSupport: () => boolean; // Immediate update callbacks registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void; @@ -118,6 +124,7 @@ interface ViewerContextType { rotationActions: RotationActions; searchActions: SearchActions; exportActions: ExportActions; + bookmarkActions: BookmarkActions; // Bridge registration - internal use by bridges registerBridge: ( @@ -135,6 +142,7 @@ interface ViewerProviderProps { export const ViewerProvider: React.FC = ({ children }) => { // UI state - only state directly managed by this context const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); + const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false); const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true); const [isAnnotationMode, setIsAnnotationModeState] = useState(false); const [activeFileIndex, setActiveFileIndex] = useState(0); @@ -193,6 +201,10 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsThumbnailSidebarVisible(prev => !prev); }; + const toggleBookmarkSidebar = () => { + setIsBookmarkSidebarVisible(prev => !prev); + }; + const toggleAnnotationsVisibility = () => { setIsAnnotationsVisible(prev => !prev); }; @@ -242,6 +254,18 @@ export const ViewerProvider: React.FC = ({ children }) => { return bridgeRefs.current.export?.state || { canExport: false }; }; + const getBookmarkState = (): BookmarkState => { + return ( + bridgeRefs.current.bookmark?.state || { + bookmarks: null, + isLoading: false, + error: null, + } + ); + }; + + const hasBookmarkSupport = () => Boolean(bridgeRefs.current.bookmark); + // Action handlers - call APIs directly const { scrollActions, @@ -252,6 +276,7 @@ export const ViewerProvider: React.FC = ({ children }) => { rotationActions, searchActions, exportActions, + bookmarkActions, } = createViewerActions({ registry: bridgeRefs, getScrollState, @@ -263,6 +288,8 @@ export const ViewerProvider: React.FC = ({ children }) => { // UI state isThumbnailSidebarVisible, toggleThumbnailSidebar, + isBookmarkSidebarVisible, + toggleBookmarkSidebar, // Annotation controls isAnnotationsVisible, @@ -285,6 +312,8 @@ export const ViewerProvider: React.FC = ({ children }) => { getSearchState, getThumbnailAPI, getExportState, + getBookmarkState, + hasBookmarkSupport, // Immediate updates registerImmediateZoomUpdate, @@ -303,6 +332,7 @@ export const ViewerProvider: React.FC = ({ children }) => { rotationActions, searchActions, exportActions, + bookmarkActions, // Bridge registration registerBridge, diff --git a/frontend/src/core/contexts/viewer/viewerActions.ts b/frontend/src/core/contexts/viewer/viewerActions.ts index daa9cb8c95..05a38440a5 100644 --- a/frontend/src/core/contexts/viewer/viewerActions.ts +++ b/frontend/src/core/contexts/viewer/viewerActions.ts @@ -5,6 +5,7 @@ import { ScrollState, ZoomState, } from '@app/contexts/viewer/viewerBridges'; +import { PdfBookmarkObject } from '@embedpdf/models'; export interface ScrollActions { scrollToPage: (page: number) => void; @@ -58,6 +59,12 @@ export interface ExportActions { saveAsCopy: () => Promise; } +export interface BookmarkActions { + fetchBookmarks: () => Promise; + clearBookmarks: () => void; + setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; +} + export interface ViewerActionsBundle { scrollActions: ScrollActions; zoomActions: ZoomActions; @@ -67,6 +74,7 @@ export interface ViewerActionsBundle { rotationActions: RotationActions; searchActions: SearchActions; exportActions: ExportActions; + bookmarkActions: BookmarkActions; } interface ViewerActionDependencies { @@ -307,5 +315,22 @@ export function createViewerActions({ rotationActions, searchActions, exportActions, + bookmarkActions: { + fetchBookmarks: async () => { + const api = registry.current.bookmark?.api; + if (!api?.fetchBookmarks) { + return null; + } + return api.fetchBookmarks(); + }, + clearBookmarks: () => { + const api = registry.current.bookmark?.api; + api?.clearBookmarks?.(); + }, + setLocalBookmarks: (bookmarks, error = null) => { + const api = registry.current.bookmark?.api; + api?.setLocalBookmarks?.(bookmarks ?? null, error); + }, + }, }; } diff --git a/frontend/src/core/contexts/viewer/viewerBridges.ts b/frontend/src/core/contexts/viewer/viewerBridges.ts index 032a05e18d..e7f1ebf3c0 100644 --- a/frontend/src/core/contexts/viewer/viewerBridges.ts +++ b/frontend/src/core/contexts/viewer/viewerBridges.ts @@ -1,4 +1,5 @@ import { SpreadMode } from '@embedpdf/plugin-spread/react'; +import { PdfBookmarkObject } from '@embedpdf/models'; export interface ScrollAPIWrapper { scrollToPage: (params: { pageNumber: number }) => void; @@ -59,6 +60,12 @@ export interface ExportAPIWrapper { saveAsCopy: () => { toPromise: () => Promise }; } +export interface BookmarkAPIWrapper { + fetchBookmarks: () => Promise; + clearBookmarks: () => void; + setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; +} + export interface ScrollState { currentPage: number; totalPages: number; @@ -103,6 +110,12 @@ export interface ExportState { canExport: boolean; } +export interface BookmarkState { + bookmarks: PdfBookmarkObject[] | null; + isLoading: boolean; + error: string | null; +} + export interface BridgeRef { state: TState; api: TApi; @@ -118,6 +131,7 @@ export interface BridgeStateMap { search: SearchState; thumbnail: unknown; export: ExportState; + bookmark: BookmarkState; } export interface BridgeApiMap { @@ -130,6 +144,7 @@ export interface BridgeApiMap { search: SearchAPIWrapper; thumbnail: ThumbnailAPIWrapper; export: ExportAPIWrapper; + bookmark: BookmarkAPIWrapper; } export type BridgeKey = keyof BridgeStateMap; @@ -148,6 +163,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({ search: null, thumbnail: null, export: null, + bookmark: null, }); export function registerBridge( diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index 8d24b32fac..140653a8fc 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -83,6 +83,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation"; import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation"; import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation"; +import { ENDPOINTS as SPLIT_ENDPOINT_NAMES } from '@app/constants/splitConstants'; import CompressSettings from "@app/components/tools/compress/CompressSettings"; import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings"; import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings"; @@ -313,6 +314,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.VERIFICATION, + endpoints: ["get-info-on-pdf"], synonyms: getSynonyms(t, "getPdfInfo"), supportsAutomate: false, automationSettings: null @@ -411,6 +413,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.split.desc", "Split PDFs into multiple documents"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))), operationConfig: splitOperationConfig, automationSettings: SplitAutomationSettings, synonyms: getSynonyms(t, "split") @@ -478,6 +481,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + endpoints: ["booklet-imposition"], }, pdfToSinglePage: { @@ -572,6 +576,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, maxFiles: -1, + endpoints: ["remove-annotations"], operationConfig: removeAnnotationsOperationConfig, automationSettings: null, synonyms: getSynonyms(t, "removeAnnotations") @@ -610,7 +615,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, maxFiles: -1, - endpoints: ["remove-certificate-sign"], + endpoints: ["remove-cert-sign"], operationConfig: removeCertificateSignOperationConfig, synonyms: getSynonyms(t, "removeCertSign"), automationSettings: null, @@ -639,7 +644,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { name: t("home.autoRename.title", "Auto Rename PDF File"), component: AutoRename, maxFiles: -1, - endpoints: ["remove-certificate-sign"], + endpoints: ["auto-rename"], operationConfig: autoRenameOperationConfig, description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"), categoryId: ToolCategoryId.ADVANCED_TOOLS, @@ -694,6 +699,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + endpoints: ["overlay-pdf"], operationConfig: overlayPdfsOperationConfig, synonyms: getSynonyms(t, "overlay-pdfs"), automationSettings: OverlayPdfsSettings @@ -718,6 +724,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.addImage.desc", "Add images to PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + endpoints: ["add-image"], synonyms: getSynonyms(t, "addImage"), automationSettings: null }, @@ -728,6 +735,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + endpoints: ["scanner-effect"], synonyms: getSynonyms(t, "scannerEffect"), automationSettings: null }, @@ -818,6 +826,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, maxFiles: -1, + endpoints: ["compress-pdf"], operationConfig: compressOperationConfig, automationSettings: CompressSettings, synonyms: getSynonyms(t, "compress") @@ -861,6 +870,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, maxFiles: -1, + endpoints: ["ocr-pdf"], operationConfig: ocrOperationConfig, automationSettings: OCRSettings, synonyms: getSynonyms(t, "ocr") diff --git a/frontend/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts b/frontend/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts index 67476734a8..838d672e5c 100644 --- a/frontend/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts +++ b/frontend/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts @@ -14,6 +14,6 @@ export type RemoveCertificateSignParametersHook = BaseParametersHook { return useBaseParameters({ defaultParameters, - endpointName: 'remove-certificate-sign', + endpointName: 'remove-cert-sign', }); }; \ No newline at end of file diff --git a/frontend/src/core/hooks/useEndpointConfig.ts b/frontend/src/core/hooks/useEndpointConfig.ts index 83bb34b572..587fb064d9 100644 --- a/frontend/src/core/hooks/useEndpointConfig.ts +++ b/frontend/src/core/hooks/useEndpointConfig.ts @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; import apiClient from '@app/services/apiClient'; +import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability'; // Track globally fetched endpoint sets to prevent duplicate fetches across components const globalFetchedSets = new Set(); -const globalEndpointCache: Record = {}; +const globalEndpointCache: Record = {}; /** * Hook to check if a specific endpoint is enabled @@ -59,11 +60,13 @@ export function useEndpointEnabled(endpoint: string): { */ export function useMultipleEndpointsEnabled(endpoints: string[]): { endpointStatus: Record; + endpointDetails: Record; loading: boolean; error: string | null; refetch: () => Promise; } { const [endpointStatus, setEndpointStatus] = useState>({}); + const [endpointDetails, setEndpointDetails] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -73,31 +76,25 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { // Skip if we already fetched these exact endpoints globally if (!force && globalFetchedSets.has(endpointsKey)) { console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache'); - const cachedStatus = endpoints.reduce((acc, endpoint) => { - if (endpoint in globalEndpointCache) { - acc[endpoint] = globalEndpointCache[endpoint]; - } - return acc; - }, {} as Record); - setEndpointStatus(cachedStatus); + const cached = endpoints.reduce( + (acc, endpoint) => { + const cachedDetails = globalEndpointCache[endpoint]; + if (cachedDetails) { + acc.status[endpoint] = cachedDetails.enabled; + acc.details[endpoint] = cachedDetails; + } + return acc; + }, + { status: {} as Record, details: {} as Record } + ); + setEndpointStatus(cached.status); + setEndpointDetails(prev => ({ ...prev, ...cached.details })); setLoading(false); return; } if (!endpoints || endpoints.length === 0) { setEndpointStatus({}); - setLoading(false); - return; - } - - // Check if JWT exists - if not, optimistically enable all endpoints - const hasJwt = !!localStorage.getItem('stirling_jwt'); - if (!hasJwt) { - console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints'); - const optimisticStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = true; - return acc; - }, {} as Record); - setEndpointStatus(optimisticStatus); + setEndpointDetails({}); setLoading(false); return; } @@ -110,11 +107,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache)); if (newEndpoints.length === 0) { console.debug('[useEndpointConfig] All endpoints already in global cache'); - const cachedStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = globalEndpointCache[endpoint]; - return acc; - }, {} as Record); - setEndpointStatus(cachedStatus); + const cached = endpoints.reduce( + (acc, endpoint) => { + const cachedDetails = globalEndpointCache[endpoint]; + if (cachedDetails) { + acc.status[endpoint] = cachedDetails.enabled; + acc.details[endpoint] = cachedDetails; + } + return acc; + }, + { status: {} as Record, details: {} as Record } + ); + setEndpointStatus(cached.status); + setEndpointDetails(prev => ({ ...prev, ...cached.details })); globalFetchedSets.add(endpointsKey); setLoading(false); return; @@ -123,30 +128,51 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { // Use batch API for efficiency - only fetch new endpoints const endpointsParam = newEndpoints.join(','); - const response = await apiClient.get>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`); + const response = await apiClient.get>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`); const statusMap = response.data; // Update global cache with new results - Object.assign(globalEndpointCache, statusMap); + Object.entries(statusMap).forEach(([endpoint, details]) => { + globalEndpointCache[endpoint] = { + enabled: details?.enabled ?? true, + reason: details?.reason ?? null, + }; + }); // Get all requested endpoints from cache (including previously cached ones) - const fullStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache - return acc; - }, {} as Record); + const fullStatus = endpoints.reduce( + (acc, endpoint) => { + const cachedDetails = globalEndpointCache[endpoint]; + if (cachedDetails) { + acc.status[endpoint] = cachedDetails.enabled; + acc.details[endpoint] = cachedDetails; + } else { + acc.status[endpoint] = true; + } + return acc; + }, + { status: {} as Record, details: {} as Record } + ); - setEndpointStatus(fullStatus); + setEndpointStatus(fullStatus.status); + setEndpointDetails(prev => ({ ...prev, ...fullStatus.details })); globalFetchedSets.add(endpointsKey); } catch (err: any) { // On 401 (auth error), use optimistic fallback instead of disabling if (err.response?.status === 401) { console.warn('[useEndpointConfig] 401 error - using optimistic fallback'); - const optimisticStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = true; - globalEndpointCache[endpoint] = true; // Cache the optimistic value - return acc; - }, {} as Record); - setEndpointStatus(optimisticStatus); + const optimisticStatus = endpoints.reduce( + (acc, endpoint) => { + const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null }; + acc.status[endpoint] = true; + acc.details[endpoint] = optimisticDetails; + globalEndpointCache[endpoint] = optimisticDetails; + return acc; + }, + { status: {} as Record, details: {} as Record } + ); + setEndpointStatus(optimisticStatus.status); + setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details })); setLoading(false); return; } @@ -156,11 +182,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { console.error('[EndpointConfig] Failed to check multiple endpoints:', err); // Fallback: assume all endpoints are enabled on error (optimistic) - const optimisticStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = true; - return acc; - }, {} as Record); - setEndpointStatus(optimisticStatus); + const optimisticStatus = endpoints.reduce( + (acc, endpoint) => { + const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null }; + acc.status[endpoint] = true; + acc.details[endpoint] = optimisticDetails; + return acc; + }, + { status: {} as Record, details: {} as Record } + ); + setEndpointStatus(optimisticStatus.status); + setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details })); } finally { setLoading(false); } @@ -186,6 +218,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { return { endpointStatus, + endpointDetails, loading, error, refetch: () => fetchAllEndpointStatuses(true), diff --git a/frontend/src/core/hooks/useToolManagement.tsx b/frontend/src/core/hooks/useToolManagement.tsx index 3f5676824b..a53fe67356 100644 --- a/frontend/src/core/hooks/useToolManagement.tsx +++ b/frontend/src/core/hooks/useToolManagement.tsx @@ -1,9 +1,20 @@ import { useState, useCallback, useMemo } from 'react'; import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; +import { usePreferences } from '@app/contexts/PreferencesContext'; import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy"; import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig"; import { FileId } from '@app/types/file'; import { ToolId } from "@app/types/toolId"; +import type { EndpointDisableReason } from '@app/types/endpointAvailability'; + +export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown'; + +export interface ToolAvailabilityInfo { + available: boolean; + reason?: ToolDisableCause; +} + +export type ToolAvailabilityMap = Partial>; interface ToolManagementResult { selectedTool: ToolRegistryEntry | null; @@ -11,6 +22,7 @@ interface ToolManagementResult { toolRegistry: Partial; setToolSelectedFileIds: (fileIds: FileId[]) => void; getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null; + toolAvailability: ToolAvailabilityMap; } export const useToolManagement = (): ToolManagementResult => { @@ -19,9 +31,10 @@ export const useToolManagement = (): ToolManagementResult => { // Build endpoints list from registry entries with fallback to legacy mapping const { allTools } = useToolRegistry(); const baseRegistry = allTools; + const { preferences } = usePreferences(); const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]); - const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); + const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const isToolAvailable = useCallback((toolKey: string): boolean => { // Keep tools enabled during loading (optimistic UX) @@ -38,22 +51,64 @@ export const useToolManagement = (): ToolManagementResult => { return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false); }, [endpointsLoading, endpointStatus, baseRegistry]); + const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => { + const tool = baseRegistry[toolKey]; + if (!tool) { + return 'unknown'; + } + const endpoints = tool.endpoints || []; + const disabledReasons: EndpointDisableReason[] = endpoints + .filter(endpoint => endpointStatus[endpoint] === false) + .map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG'); + + if (disabledReasons.some(reason => reason === 'DEPENDENCY')) { + return 'missingDependency'; + } + if (disabledReasons.some(reason => reason === 'CONFIG')) { + return 'disabledByAdmin'; + } + if (disabledReasons.length > 0) { + return 'unknown'; + } + return 'unknown'; + }, [baseRegistry, endpointDetails, endpointStatus]); + + const toolAvailability = useMemo(() => { + if (endpointsLoading) { + return {}; + } + const availability: ToolAvailabilityMap = {}; + (Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => { + const available = isToolAvailable(toolKey); + availability[toolKey] = available + ? { available: true } + : { available: false, reason: deriveToolDisableReason(toolKey) }; + }); + return availability; + }, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]); + const toolRegistry: Partial = useMemo(() => { const availableToolRegistry: Partial = {}; (Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => { - if (isToolAvailable(toolKey)) { - const baseTool = baseRegistry[toolKey]; - if (baseTool) { - availableToolRegistry[toolKey] = { - ...baseTool, - name: baseTool.name, - description: baseTool.description, - }; - } + const baseTool = baseRegistry[toolKey]; + if (!baseTool) return; + const availabilityInfo = toolAvailability[toolKey]; + const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true; + + // Check if tool is "coming soon" (has no component and no link) + const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool'; + + if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) { + return; } + availableToolRegistry[toolKey] = { + ...baseTool, + name: baseTool.name, + description: baseTool.description, + }; }); return availableToolRegistry; - }, [isToolAvailable, baseRegistry]); + }, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]); const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => { return toolKey ? toolRegistry[toolKey] || null : null; @@ -65,5 +120,6 @@ export const useToolManagement = (): ToolManagementResult => { toolRegistry, setToolSelectedFileIds, getSelectedTool, + toolAvailability, }; }; diff --git a/frontend/src/core/services/preferencesService.ts b/frontend/src/core/services/preferencesService.ts index 5894eae506..bc30f453c5 100644 --- a/frontend/src/core/services/preferencesService.ts +++ b/frontend/src/core/services/preferencesService.ts @@ -9,6 +9,8 @@ export interface UserPreferences { toolPanelModePromptSeen: boolean; showLegacyToolDescriptions: boolean; hasCompletedOnboarding: boolean; + hideUnavailableTools: boolean; + hideUnavailableConversions: boolean; } export const DEFAULT_PREFERENCES: UserPreferences = { @@ -19,6 +21,8 @@ export const DEFAULT_PREFERENCES: UserPreferences = { toolPanelModePromptSeen: false, showLegacyToolDescriptions: false, hasCompletedOnboarding: false, + hideUnavailableTools: false, + hideUnavailableConversions: false, }; const STORAGE_KEY = 'stirlingpdf_preferences'; diff --git a/frontend/src/core/services/updateService.ts b/frontend/src/core/services/updateService.ts index 1cba4838fa..f134e403e1 100644 --- a/frontend/src/core/services/updateService.ts +++ b/frontend/src/core/services/updateService.ts @@ -1,6 +1,6 @@ export interface UpdateSummary { - latest_version: string; - latest_stable_version?: string; + latest_version: string | null; + latest_stable_version?: string | null; max_priority: 'urgent' | 'normal' | 'minor' | 'low'; recommended_action?: string; any_breaking: boolean; diff --git a/frontend/src/core/types/endpointAvailability.ts b/frontend/src/core/types/endpointAvailability.ts new file mode 100644 index 0000000000..c0f37d8312 --- /dev/null +++ b/frontend/src/core/types/endpointAvailability.ts @@ -0,0 +1,6 @@ +export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null; + +export interface EndpointAvailabilityDetails { + enabled: boolean; + reason?: EndpointDisableReason; +} diff --git a/frontend/src/desktop/hooks/useEndpointConfig.ts b/frontend/src/desktop/hooks/useEndpointConfig.ts index 8ccdc66e21..f0156b6dc2 100644 --- a/frontend/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/src/desktop/hooks/useEndpointConfig.ts @@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next'; import apiClient from '@app/services/apiClient'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { isBackendNotReadyError } from '@app/constants/backendErrors'; +import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability'; import { connectionModeService } from '@desktop/services/connectionModeService'; + interface EndpointConfig { backendUrl: string; } @@ -128,6 +130,7 @@ export function useEndpointEnabled(endpoint: string): { export function useMultipleEndpointsEnabled(endpoints: string[]): { endpointStatus: Record; + endpointDetails: Record; loading: boolean; error: string | null; refetch: () => Promise; @@ -140,6 +143,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { return acc; }, {} as Record); }); + const [endpointDetails, setEndpointDetails] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const isMountedRef = useRef(true); @@ -174,13 +178,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { const endpointsParam = endpoints.join(','); - const response = await apiClient.get>('/api/v1/config/endpoints-enabled', { + const response = await apiClient.get>('/api/v1/config/endpoints-availability', { params: { endpoints: endpointsParam }, suppressErrorToast: true, }); if (!isMountedRef.current) return; - setEndpointStatus(response.data); + const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => { + acc[endpointName] = { + enabled: detail?.enabled ?? true, + reason: detail?.reason ?? null, + }; + return acc; + }, {} as Record); + + const statusMap = Object.keys(details).reduce((acc, key) => { + acc[key] = details[key].enabled; + return acc; + }, {} as Record); + + setEndpointDetails(prev => ({ ...prev, ...details })); + setEndpointStatus(statusMap); } catch (err: unknown) { const isBackendStarting = isBackendNotReadyError(err); const message = getErrorMessage(err); @@ -188,10 +206,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message); const fallbackStatus = endpoints.reduce((acc, endpointName) => { - acc[endpointName] = true; + const fallbackDetail: EndpointAvailabilityDetails = { enabled: true, reason: null }; + acc.status[endpointName] = true; + acc.details[endpointName] = fallbackDetail; return acc; - }, {} as Record); - setEndpointStatus(fallbackStatus); + }, { status: {} as Record, details: {} as Record }); + setEndpointStatus(fallbackStatus.status); + setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details })); if (!retryTimeoutRef.current) { retryTimeoutRef.current = setTimeout(() => { @@ -209,6 +230,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { useEffect(() => { if (!endpoints || endpoints.length === 0) { setEndpointStatus({}); + setEndpointDetails({}); setLoading(false); return; } @@ -230,6 +252,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { return { endpointStatus, + endpointDetails, loading, error, refetch: fetchAllEndpointStatuses,