From e4d480c7b3c02abbf2ab930e63da0828506c4041 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 25 Jul 2025 09:37:52 +0100 Subject: [PATCH 01/29] Feature/v2/multiselect (#4024) # Description of Changes This pull request introduces significant updates to the file selection logic, tool rendering, and file context management in the frontend codebase. The changes aim to improve modularity, enhance maintainability, and streamline the handling of file-related operations. Key updates include the introduction of a new `FileSelectionContext`, refactoring of file selection logic, and updates to tool management and rendering. ### File Selection Context and Logic Refactor: * Added a new `FileSelectionContext` to centralize file selection state and provide utility hooks for managing selected files, selection limits, and tool mode. (`frontend/src/contexts/FileSelectionContext.tsx`, [frontend/src/contexts/FileSelectionContext.tsxR1-R77](diffhunk://#diff-bda35f1aaa5eafa0a0dc48e0b1270d862f6da360ba1241234e891f0ca8907327R1-R77)) * Replaced local file selection logic in `FileEditor` with context-based logic, improving consistency and reducing duplication. (`frontend/src/components/fileEditor/FileEditor.tsx`, [[1]](diffhunk://#diff-481d0a2d8a1714d34d21181db63a020b08dfccfbfa80bf47ac9af382dff25310R63-R70) [[2]](diffhunk://#diff-481d0a2d8a1714d34d21181db63a020b08dfccfbfa80bf47ac9af382dff25310R404-R438) ### Tool Management and Rendering: * Refactored `ToolRenderer` to use a `Suspense` fallback for lazy-loaded tools, improving user experience during tool loading. (`frontend/src/components/tools/ToolRenderer.tsx`, [frontend/src/components/tools/ToolRenderer.tsxL32-L64](diffhunk://#diff-2083701113aa92cd1f5ce1b4b52cc233858e31ed7bcf39c5bfb1bcc34e99b6a9L32-L64)) * Simplified `ToolPicker` by reusing the `ToolRegistry` type, reducing redundancy. (`frontend/src/components/tools/ToolPicker.tsx`, [frontend/src/components/tools/ToolPicker.tsxL4-R4](diffhunk://#diff-e47deca9132018344c159925f1264794acdd57f4b65e582eb9b2a4ea69ec126dL4-R4)) ### File Context Enhancements: * Introduced a utility function `getFileId` for consistent file ID extraction, replacing repetitive inline logic. (`frontend/src/contexts/FileContext.tsx`, [[1]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcR25) [[2]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcL101-R102) * Updated `FileContextProvider` to use more specific types for PDF documents, enhancing type safety. (`frontend/src/contexts/FileContext.tsx`, [[1]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcL350-R351) [[2]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcL384-R385) ### Compression Tool Enhancements: * Added blob URL cleanup logic to the compression hook to prevent memory leaks. (`frontend/src/hooks/tools/compress/useCompressOperation.ts`, [frontend/src/hooks/tools/compress/useCompressOperation.tsR58-L66](diffhunk://#diff-d7815fea0e89989511ae1786f7031cba492b9f2db39b7ade92d9736d1bd4b673R58-L66)) * Adjusted file ID generation in the compression operation to handle multiple files more effectively. (`frontend/src/hooks/tools/compress/useCompressOperation.ts`, [frontend/src/hooks/tools/compress/useCompressOperation.tsL90-R102](diffhunk://#diff-d7815fea0e89989511ae1786f7031cba492b9f2db39b7ade92d9736d1bd4b673L90-R102)) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../src/components/fileEditor/FileEditor.tsx | 108 +++++----- .../components/tools/ToolLoadingFallback.tsx | 14 ++ frontend/src/components/tools/ToolPicker.tsx | 10 +- .../src/components/tools/ToolRenderer.tsx | 62 ++---- frontend/src/contexts/FileContext.tsx | 11 +- .../src/contexts/FileSelectionContext.tsx | 86 ++++++++ .../tools/compress/useCompressOperation.ts | 200 ++++++++++-------- frontend/src/hooks/useToolManagement.tsx | 77 ++++--- frontend/src/pages/HomePage.tsx | 82 +++---- frontend/src/services/zipFileService.ts | 31 +++ frontend/src/tools/Compress.tsx | 35 +-- frontend/src/tools/Split.tsx | 30 +-- frontend/src/types/pageEditor.ts | 16 ++ frontend/src/types/tool.ts | 73 +++++++ frontend/src/utils/fileUtils.ts | 4 + 15 files changed, 538 insertions(+), 301 deletions(-) create mode 100644 frontend/src/components/tools/ToolLoadingFallback.tsx create mode 100644 frontend/src/contexts/FileSelectionContext.tsx create mode 100644 frontend/src/types/tool.ts diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index c0badafd8..9e0dc2171 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -7,6 +7,7 @@ import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import { useFileContext } from '../../contexts/FileContext'; +import { useFileSelection } from '../../contexts/FileSelectionContext'; import { FileOperation } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; @@ -31,20 +32,16 @@ interface FileEditorProps { onOpenPageEditor?: (file: File) => void; onMergeFiles?: (files: File[]) => void; toolMode?: boolean; - multiSelect?: boolean; showUpload?: boolean; showBulkActions?: boolean; - onFileSelect?: (files: File[]) => void; } const FileEditor = ({ onOpenPageEditor, onMergeFiles, toolMode = false, - multiSelect = true, showUpload = true, - showBulkActions = true, - onFileSelect + showBulkActions = true }: FileEditorProps) => { const { t } = useTranslation(); @@ -63,6 +60,14 @@ const FileEditor = ({ markOperationApplied } = fileContext; + // Get file selection context + const { + selectedFiles: toolSelectedFiles, + setSelectedFiles: setToolSelectedFiles, + maxFiles, + isToolMode + } = useFileSelection(); + const [files, setFiles] = useState([]); const [status, setStatus] = useState(null); const [error, setError] = useState(null); @@ -99,14 +104,14 @@ const FileEditor = ({ const lastActiveFilesRef = useRef([]); const lastProcessedFilesRef = useRef(0); - // Map context selected file names to local file IDs - // Defensive programming: ensure selectedFileIds is always an array - const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; + // Get selected file IDs from context (defensive programming) + const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; - const localSelectedFiles = files + // Map context selections to local file IDs for UI display + const localSelectedIds = files .filter(file => { const fileId = (file.file as any).id || file.name; - return safeSelectedFileIds.includes(fileId); + return contextSelectedIds.includes(fileId); }) .map(file => file.id); @@ -396,44 +401,41 @@ const FileEditor = ({ if (!targetFile) return; const contextFileId = (targetFile.file as any).id || targetFile.name; + const isSelected = contextSelectedIds.includes(contextFileId); - if (!multiSelect) { - // Single select mode for tools - toggle on/off - const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId); - if (isCurrentlySelected) { - // Deselect the file - setContextSelectedFiles([]); - if (onFileSelect) { - onFileSelect([]); - } - } else { - // Select the file - setContextSelectedFiles([contextFileId]); - if (onFileSelect) { - onFileSelect([targetFile.file]); - } - } + let newSelection: string[]; + + if (isSelected) { + // Remove file from selection + newSelection = contextSelectedIds.filter(id => id !== contextFileId); } else { - // Multi select mode (default) - setContextSelectedFiles(prev => { - const safePrev = Array.isArray(prev) ? prev : []; - return safePrev.includes(contextFileId) - ? safePrev.filter(id => id !== contextFileId) - : [...safePrev, contextFileId]; - }); - - // Notify parent with selected files - if (onFileSelect) { - const selectedFiles = files - .filter(f => { - const fId = (f.file as any).id || f.name; - return safeSelectedFileIds.includes(fId) || fId === contextFileId; - }) - .map(f => f.file); - onFileSelect(selectedFiles); + // Add file to selection + if (maxFiles === 1) { + newSelection = [contextFileId]; + } else { + // Check if we've hit the selection limit + if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) { + setStatus(`Maximum ${maxFiles} files can be selected`); + return; + } + newSelection = [...contextSelectedIds, contextFileId]; } } - }, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]); + + // Update context + setContextSelectedFiles(newSelection); + + // Update tool selection context if in tool mode + if (isToolMode || toolMode) { + const selectedFiles = files + .filter(f => { + const fId = (f.file as any).id || f.name; + return newSelection.includes(fId); + }) + .map(f => f.file); + setToolSelectedFiles(selectedFiles); + } + }, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { @@ -450,15 +452,15 @@ const FileEditor = ({ const handleDragStart = useCallback((fileId: string) => { setDraggedFile(fileId); - if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) { + if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) { setMultiFileDrag({ - fileIds: localSelectedFiles, - count: localSelectedFiles.length + fileIds: localSelectedIds, + count: localSelectedIds.length }); } else { setMultiFileDrag(null); } - }, [selectionMode, localSelectedFiles]); + }, [selectionMode, localSelectedIds]); const handleDragEnd = useCallback(() => { setDraggedFile(null); @@ -519,8 +521,8 @@ const FileEditor = ({ if (targetIndex === -1) return; } - const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile) - ? localSelectedFiles + const filesToMove = selectionMode && localSelectedIds.includes(draggedFile) + ? localSelectedIds : [draggedFile]; // Update the local files state and sync with activeFiles @@ -545,7 +547,7 @@ const FileEditor = ({ const moveCount = multiFileDrag ? multiFileDrag.count : 1; setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - }, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]); + }, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]); const handleEndZoneDragEnter = useCallback(() => { if (draggedFile) { @@ -764,7 +766,7 @@ const FileEditor = ({ ) : ( + + + + {toolName ? `Loading ${toolName}...` : "Loading tool..."} + + + + ) +} diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index cfb2bd3d4..c22a0f60f 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -1,15 +1,7 @@ import React, { useState } from "react"; import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; - -type Tool = { - icon: React.ReactNode; - name: string; -}; - -type ToolRegistry = { - [id: string]: Tool; -}; +import { ToolRegistry } from "../../types/tool"; interface ToolPickerProps { selectedToolKey: string | null; diff --git a/frontend/src/components/tools/ToolRenderer.tsx b/frontend/src/components/tools/ToolRenderer.tsx index 032d84828..493470935 100644 --- a/frontend/src/components/tools/ToolRenderer.tsx +++ b/frontend/src/components/tools/ToolRenderer.tsx @@ -1,23 +1,18 @@ -import { FileWithUrl } from "../../types/file"; +import React, { Suspense } from "react"; import { useToolManagement } from "../../hooks/useToolManagement"; +import { BaseToolProps } from "../../types/tool"; +import ToolLoadingFallback from "./ToolLoadingFallback"; -interface ToolRendererProps { +interface ToolRendererProps extends BaseToolProps { selectedToolKey: string; - pdfFile: any; - files: FileWithUrl[]; - toolParams: any; - updateParams: (params: any) => void; - toolSelectedFiles?: File[]; - onPreviewFile?: (file: File | null) => void; } + const ToolRenderer = ({ selectedToolKey, -files, - toolParams, - updateParams, - toolSelectedFiles = [], onPreviewFile, + onComplete, + onError, }: ToolRendererProps) => { // Get the tool from registry const { toolRegistry } = useToolManagement(); @@ -29,39 +24,16 @@ files, const ToolComponent = selectedTool.component; - // Pass tool-specific props - switch (selectedToolKey) { - case "split": - return ( - - ); - case "compress": - return ( - - ); - case "merge": - return ( - - ); - default: - return ( - - ); - } + // Wrap lazy-loaded component with Suspense + return ( + }> + + + ); }; export default ToolRenderer; diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 811a49db7..6e8a42fab 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -22,6 +22,7 @@ import { useEnhancedProcessedFiles } from '../hooks/useEnhancedProcessedFiles'; import { fileStorage } from '../services/fileStorage'; import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; +import { getFileId } from '../utils/fileUtils'; // Initial state const initialViewerConfig: ViewerConfig = { @@ -98,7 +99,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): case 'REMOVE_FILES': const remainingFiles = state.activeFiles.filter(file => { - const fileId = (file as any).id || file.name; + const fileId = getFileId(file); return !action.payload.includes(fileId); }); const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : []; @@ -347,7 +348,7 @@ export function FileContextProvider({ // Cleanup timers and refs const cleanupTimers = useRef>(new Map()); const blobUrls = useRef>(new Set()); - const pdfDocuments = useRef>(new Map()); + const pdfDocuments = useRef>(new Map()); // Enhanced file processing hook const { @@ -381,7 +382,7 @@ export function FileContextProvider({ blobUrls.current.add(url); }, []); - const trackPdfDocument = useCallback((fileId: string, pdfDoc: any) => { + const trackPdfDocument = useCallback((fileId: string, pdfDoc: PDFDocument) => { // Clean up existing document for this file if any const existing = pdfDocuments.current.get(fileId); if (existing && existing.destroy) { @@ -498,7 +499,7 @@ export function FileContextProvider({ for (const file of files) { try { // Check if file already has an ID (already in IndexedDB) - const fileId = (file as any).id; + const fileId = getFileId(file); if (!fileId) { // File doesn't have ID, store it and get the ID const storedFile = await fileStorage.storeFile(file); @@ -680,7 +681,7 @@ export function FileContextProvider({ // Utility functions const getFileById = useCallback((fileId: string): File | undefined => { return state.activeFiles.find(file => { - const actualFileId = (file as any).id || file.name; + const actualFileId = getFileId(file); return actualFileId === fileId; }); }, [state.activeFiles]); diff --git a/frontend/src/contexts/FileSelectionContext.tsx b/frontend/src/contexts/FileSelectionContext.tsx new file mode 100644 index 000000000..2c79882b2 --- /dev/null +++ b/frontend/src/contexts/FileSelectionContext.tsx @@ -0,0 +1,86 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { + MaxFiles, + FileSelectionContextValue +} from '../types/tool'; + +interface FileSelectionProviderProps { + children: ReactNode; +} + +const FileSelectionContext = createContext(undefined); + +export function FileSelectionProvider({ children }: FileSelectionProviderProps) { + const [selectedFiles, setSelectedFiles] = useState([]); + const [maxFiles, setMaxFiles] = useState(-1); + const [isToolMode, setIsToolMode] = useState(false); + + const clearSelection = useCallback(() => { + setSelectedFiles([]); + }, []); + + const selectionCount = selectedFiles.length; + const canSelectMore = maxFiles === -1 || selectionCount < maxFiles; + const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles; + const isMultiFileMode = maxFiles !== 1; + + const contextValue: FileSelectionContextValue = { + selectedFiles, + maxFiles, + isToolMode, + setSelectedFiles, + setMaxFiles, + setIsToolMode, + clearSelection, + canSelectMore, + isAtLimit, + selectionCount, + isMultiFileMode + }; + + return ( + + {children} + + ); +} + +/** + * Access the file selection context. + * Throws if used outside a . + */ +export function useFileSelection(): FileSelectionContextValue { + const context = useContext(FileSelectionContext); + if (!context) { + throw new Error('useFileSelection must be used within a FileSelectionProvider'); + } + return context; +} + +// Returns only the file selection values relevant for tools (e.g. merge, split, etc.) +// Use this in tool panels/components that need to know which files are selected and selection limits. +export function useToolFileSelection(): Pick { + const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection(); + return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount }; +} + +// Returns actions for manipulating file selection state. +// Use this in components that need to update the selection, clear it, or change selection mode. +export function useFileSelectionActions(): Pick { + const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection(); + return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode }; +} + +// Returns the raw file selection state (selected files, max files, tool mode). +// Use this for low-level state access, e.g. in context-aware UI. +export function useFileSelectionState(): Pick { + const { selectedFiles, maxFiles, isToolMode } = useFileSelection(); + return { selectedFiles, maxFiles, isToolMode }; +} + +// Returns computed values derived from file selection state. +// Use this for file selection UI logic (e.g. disabling buttons when at limit). +export function useFileSelectionComputed(): Pick { + const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection(); + return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode }; +} diff --git a/frontend/src/hooks/tools/compress/useCompressOperation.ts b/frontend/src/hooks/tools/compress/useCompressOperation.ts index 4582068a6..e66b3f43f 100644 --- a/frontend/src/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/src/hooks/tools/compress/useCompressOperation.ts @@ -20,7 +20,7 @@ export interface CompressOperationHook { parameters: CompressParameters, selectedFiles: File[] ) => Promise; - + // Flattened result properties for cleaner access files: File[]; thumbnails: string[]; @@ -30,7 +30,7 @@ export interface CompressOperationHook { status: string; errorMessage: string | null; isLoading: boolean; - + // Result management functions resetResults: () => void; clearError: () => void; @@ -38,13 +38,13 @@ export interface CompressOperationHook { export const useCompressOperation = (): CompressOperationHook => { const { t } = useTranslation(); - const { - recordOperation, - markOperationApplied, + const { + recordOperation, + markOperationApplied, markOperationFailed, addFiles } = useFileContext(); - + // Internal state management const [files, setFiles] = useState([]); const [thumbnails, setThumbnails] = useState([]); @@ -55,15 +55,27 @@ export const useCompressOperation = (): CompressOperationHook => { const [errorMessage, setErrorMessage] = useState(null); const [isLoading, setIsLoading] = useState(false); + // Track blob URLs for cleanup + const [blobUrls, setBlobUrls] = useState([]); + + const cleanupBlobUrls = useCallback(() => { + blobUrls.forEach(url => { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.warn('Failed to revoke blob URL:', error); + } + }); + setBlobUrls([]); + }, [blobUrls]); + const buildFormData = useCallback(( parameters: CompressParameters, - selectedFiles: File[] + file: File ) => { const formData = new FormData(); - - selectedFiles.forEach(file => { - formData.append("fileInput", file); - }); + + formData.append("fileInput", file); if (parameters.compressionMethod === 'quality') { formData.append("optimizeLevel", parameters.compressionLevel.toString()); @@ -74,7 +86,7 @@ export const useCompressOperation = (): CompressOperationHook => { formData.append("expectedOutputSize", fileSize); } } - + formData.append("grayscale", parameters.grayscale.toString()); const endpoint = "/api/v1/misc/compress-pdf"; @@ -87,7 +99,7 @@ export const useCompressOperation = (): CompressOperationHook => { selectedFiles: File[] ): { operation: FileOperation; operationId: string; fileId: string } => { const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const fileId = selectedFiles[0].name; + const fileId = selectedFiles.map(f => f.name).join(','); const operation: FileOperation = { id: operationId, @@ -96,74 +108,20 @@ export const useCompressOperation = (): CompressOperationHook => { fileIds: selectedFiles.map(f => f.name), status: 'pending', metadata: { - originalFileName: selectedFiles[0].name, + originalFileNames: selectedFiles.map(f => f.name), parameters: { compressionLevel: parameters.compressionLevel, grayscale: parameters.grayscale, expectedSize: parameters.expectedSize, }, - fileSize: selectedFiles[0].size + totalFileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0), + fileCount: selectedFiles.length } }; return { operation, operationId, fileId }; }, []); - const processResults = useCallback(async (blob: Blob, selectedFiles: File[]) => { - try { - // Check if the response is a PDF file directly or a ZIP file - const contentType = blob.type; - console.log('Response content type:', contentType); - - if (contentType === 'application/pdf') { - // Direct PDF response - const originalFileName = selectedFiles[0].name; - const pdfFile = new File([blob], `compressed_${originalFileName}`, { type: "application/pdf" }); - setFiles([pdfFile]); - setThumbnails([]); - setIsGeneratingThumbnails(true); - - // Add file to FileContext - await addFiles([pdfFile]); - - // Generate thumbnail - const thumbnail = await generateThumbnailForFile(pdfFile); - setThumbnails([thumbnail || '']); - setIsGeneratingThumbnails(false); - } else { - // ZIP file response (like split operation) - const zipFile = new File([blob], "compress_result.zip", { type: "application/zip" }); - const extractionResult = await zipFileService.extractPdfFiles(zipFile); - - if (extractionResult.success && extractionResult.extractedFiles.length > 0) { - // Set local state for preview - setFiles(extractionResult.extractedFiles); - setThumbnails([]); - setIsGeneratingThumbnails(true); - - // Add extracted files to FileContext for future use - await addFiles(extractionResult.extractedFiles); - - const thumbnails = await Promise.all( - extractionResult.extractedFiles.map(async (file) => { - try { - const thumbnail = await generateThumbnailForFile(file); - return thumbnail || ''; - } catch (error) { - console.warn(`Failed to generate thumbnail for ${file.name}:`, error); - return ''; - } - }) - ); - - setThumbnails(thumbnails); - setIsGeneratingThumbnails(false); - } - } - } catch (extractError) { - console.warn('Failed to process results:', extractError); - } - }, [addFiles]); const executeOperation = useCallback(async ( parameters: CompressParameters, @@ -173,32 +131,93 @@ export const useCompressOperation = (): CompressOperationHook => { setStatus(t("noFileSelected")); return; } + const validFiles = selectedFiles.filter(file => file.size > 0); + if (validFiles.length === 0) { + setErrorMessage('No valid files to compress. All selected files are empty.'); + return; + } + + if (validFiles.length < selectedFiles.length) { + console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`); + } const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); - const { formData, endpoint } = buildFormData(parameters, selectedFiles); recordOperation(fileId, operation); setStatus(t("loading")); setIsLoading(true); setErrorMessage(null); + setFiles([]); + setThumbnails([]); try { - const response = await axios.post(endpoint, formData, { responseType: "blob" }); - - // Determine the correct content type from the response - const contentType = response.headers['content-type'] || 'application/zip'; - const blob = new Blob([response.data], { type: contentType }); - const url = window.URL.createObjectURL(blob); - - // Generate dynamic filename based on original file and content type - const originalFileName = selectedFiles[0].name; - const filename = `compressed_${originalFileName}`; - setDownloadFilename(filename); - setDownloadUrl(url); - setStatus(t("downloadComplete")); + const compressedFiles: File[] = []; - await processResults(blob, selectedFiles); + const failedFiles: string[] = []; + + for (let i = 0; i < validFiles.length; i++) { + const file = validFiles[i]; + setStatus(`Compressing ${file.name} (${i + 1}/${validFiles.length})`); + + try { + const { formData, endpoint } = buildFormData(parameters, file); + const response = await axios.post(endpoint, formData, { responseType: "blob" }); + + const contentType = response.headers['content-type'] || 'application/pdf'; + const blob = new Blob([response.data], { type: contentType }); + const compressedFile = new File([blob], `compressed_${file.name}`, { type: contentType }); + + compressedFiles.push(compressedFile); + } catch (fileError) { + console.error(`Failed to compress ${file.name}:`, fileError); + failedFiles.push(file.name); + } + } + + if (failedFiles.length > 0 && compressedFiles.length === 0) { + throw new Error(`Failed to compress all files: ${failedFiles.join(', ')}`); + } + + if (failedFiles.length > 0) { + setStatus(`Compressed ${compressedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`); + } + + setFiles(compressedFiles); + setIsGeneratingThumbnails(true); + + await addFiles(compressedFiles); + + cleanupBlobUrls(); + + if (compressedFiles.length === 1) { + const url = window.URL.createObjectURL(compressedFiles[0]); + setDownloadUrl(url); + setBlobUrls([url]); + setDownloadFilename(`compressed_${selectedFiles[0].name}`); + } else { + const { zipFile } = await zipFileService.createZipFromFiles(compressedFiles, 'compressed_files.zip'); + const url = window.URL.createObjectURL(zipFile); + setDownloadUrl(url); + setBlobUrls([url]); + setDownloadFilename(`compressed_${validFiles.length}_files.zip`); + } + + const thumbnails = await Promise.all( + compressedFiles.map(async (file) => { + try { + const thumbnail = await generateThumbnailForFile(file); + return thumbnail || ''; + } catch (error) { + console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + return ''; + } + }) + ); + + setThumbnails(thumbnails); + setIsGeneratingThumbnails(false); + setStatus(t("downloadComplete")); markOperationApplied(fileId, operationId); } catch (error: any) { console.error(error); @@ -214,9 +233,10 @@ export const useCompressOperation = (): CompressOperationHook => { } finally { setIsLoading(false); } - }, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]); + }, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, addFiles]); const resetResults = useCallback(() => { + cleanupBlobUrls(); setFiles([]); setThumbnails([]); setIsGeneratingThumbnails(false); @@ -224,7 +244,7 @@ export const useCompressOperation = (): CompressOperationHook => { setStatus(''); setErrorMessage(null); setIsLoading(false); - }, []); + }, [cleanupBlobUrls]); const clearError = useCallback(() => { setErrorMessage(null); @@ -232,8 +252,6 @@ export const useCompressOperation = (): CompressOperationHook => { return { executeOperation, - - // Flattened result properties for cleaner access files, thumbnails, isGeneratingThumbnails, @@ -242,9 +260,9 @@ export const useCompressOperation = (): CompressOperationHook => { status, errorMessage, isLoading, - + // Result management functions resetResults, clearError, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 317d6abbc..7ada59024 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -1,64 +1,75 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import AddToPhotosIcon from "@mui/icons-material/AddToPhotos"; import ContentCutIcon from "@mui/icons-material/ContentCut"; import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; -import SplitPdfPanel from "../tools/Split"; -import CompressPdfPanel from "../tools/Compress"; -import MergePdfPanel from "../tools/Merge"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; +import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool"; -type ToolRegistryEntry = { - icon: React.ReactNode; - name: string; - component: React.ComponentType; - view: string; -}; -type ToolRegistry = { - [key: string]: ToolRegistryEntry; -}; +// Add entry here with maxFiles, endpoints, and lazy component +const toolDefinitions: Record = { + split: { + id: "split", + icon: , + component: React.lazy(() => import("../tools/Split")), + maxFiles: 1, + category: "manipulation", + description: "Split PDF files into smaller parts", + endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"] + }, + compress: { + id: "compress", + icon: , + component: React.lazy(() => import("../tools/Compress")), + maxFiles: -1, + category: "optimization", + description: "Reduce PDF file size", + endpoints: ["compress-pdf"] + }, -const baseToolRegistry = { - split: { icon: , component: SplitPdfPanel, view: "split" }, - compress: { icon: , component: CompressPdfPanel, view: "compress" }, - merge: { icon: , component: MergePdfPanel, view: "pageEditor" }, -}; - -// Tool endpoint mappings -const toolEndpoints: Record = { - split: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"], - compress: ["compress-pdf"], - merge: ["merge-pdfs"], }; -export const useToolManagement = () => { +interface ToolManagementResult { + selectedToolKey: string | null; + selectedTool: Tool | null; + toolSelectedFileIds: string[]; + toolRegistry: ToolRegistry; + selectTool: (toolKey: string) => void; + clearToolSelection: () => void; + setToolSelectedFileIds: (fileIds: string[]) => void; +} + +export const useToolManagement = (): ToolManagementResult => { const { t } = useTranslation(); const [selectedToolKey, setSelectedToolKey] = useState(null); const [toolSelectedFileIds, setToolSelectedFileIds] = useState([]); - const allEndpoints = Array.from(new Set(Object.values(toolEndpoints).flat())); + const allEndpoints = Array.from(new Set( + Object.values(toolDefinitions).flatMap(tool => tool.endpoints || []) + )); const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const isToolAvailable = useCallback((toolKey: string): boolean => { if (endpointsLoading) return true; - const endpoints = toolEndpoints[toolKey] || []; - return endpoints.some(endpoint => endpointStatus[endpoint] === true); + const tool = toolDefinitions[toolKey]; + if (!tool?.endpoints) return true; + return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true); }, [endpointsLoading, endpointStatus]); const toolRegistry: ToolRegistry = useMemo(() => { - const availableToolRegistry: ToolRegistry = {}; - Object.keys(baseToolRegistry).forEach(toolKey => { + const availableTools: ToolRegistry = {}; + Object.keys(toolDefinitions).forEach(toolKey => { if (isToolAvailable(toolKey)) { - availableToolRegistry[toolKey] = { - ...baseToolRegistry[toolKey as keyof typeof baseToolRegistry], + const toolDef = toolDefinitions[toolKey]; + availableTools[toolKey] = { + ...toolDef, name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1)) }; } }); - return availableToolRegistry; + return availableTools; }, [t, isToolAvailable]); useEffect(() => { diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index eb4908856..1ee0c99ed 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,9 +1,11 @@ -import React, { useState, useCallback} from "react"; +import React, { useState, useCallback, useEffect} from "react"; import { useTranslation } from 'react-i18next'; import { useFileContext } from "../contexts/FileContext"; +import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; import { useToolManagement } from "../hooks/useToolManagement"; import { Group, Box, Button, Container } from "@mantine/core"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; +import { PageEditorFunctions } from "../types/pageEditor"; import rainbowStyles from '../styles/rainbow.module.css'; import ToolPicker from "../components/tools/ToolPicker"; @@ -15,45 +17,50 @@ import Viewer from "../components/viewer/Viewer"; import FileUploadSelector from "../components/shared/FileUploadSelector"; import ToolRenderer from "../components/tools/ToolRenderer"; import QuickAccessBar from "../components/shared/QuickAccessBar"; -import { useMultipleEndpointsEnabled } from "../hooks/useEndpointConfig"; -export default function HomePage() { +function HomePageContent() { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); - // Get file context const fileContext = useFileContext(); const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext; + const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); const { selectedToolKey, selectedTool, - toolParams, toolRegistry, selectTool, clearToolSelection, - updateToolParams, } = useToolManagement(); - - const [toolSelectedFiles, setToolSelectedFiles] = useState([]); const [sidebarsVisible, setSidebarsVisible] = useState(true); const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); const [readerMode, setReaderMode] = useState(false); - const [pageEditorFunctions, setPageEditorFunctions] = useState(null); + const [pageEditorFunctions, setPageEditorFunctions] = useState(null); const [previewFile, setPreviewFile] = useState(null); - + // Update file selection context when tool changes + useEffect(() => { + if (selectedTool) { + setMaxFiles(selectedTool.maxFiles); + setIsToolMode(true); + } else { + setMaxFiles(-1); + setIsToolMode(false); + setSelectedFiles([]); + } + }, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]); const handleToolSelect = useCallback( (id: string) => { selectTool(id); - if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view); + setCurrentView('fileEditor'); // Tools use fileEditor view for file selection setLeftPanelView('toolContent'); setReaderMode(false); }, - [selectTool, toolRegistry, setCurrentView] + [selectTool, setCurrentView] ); const handleQuickAccessTools = useCallback(() => { @@ -145,7 +152,6 @@ export default function HomePage() {
@@ -196,14 +202,18 @@ export default function HomePage() { ) : currentView === "fileEditor" ? ( { - handleViewChange("pageEditor"); - }} - onMergeFiles={(filesToMerge) => { - // Add merged files to active set - filesToMerge.forEach(addToActiveFiles); - handleViewChange("viewer"); - }} + toolMode={!!selectedToolKey} + showUpload={true} + showBulkActions={!selectedToolKey} + {...(!selectedToolKey && { + onOpenPageEditor: (file) => { + handleViewChange("pageEditor"); + }, + onMergeFiles: (filesToMerge) => { + filesToMerge.forEach(addToActiveFiles); + handleViewChange("viewer"); + } + })} /> ) : currentView === "viewer" ? ( )} - ) : currentView === "split" ? ( - { - setToolSelectedFiles(files); - }} - /> - ) : currentView === "compress" ? ( - { - setToolSelectedFiles(files); - }} - /> ) : selectedToolKey && selectedTool ? ( + // Fallback: if tool is selected but not in fileEditor view, show tool in main area @@ -300,3 +291,12 @@ export default function HomePage() { ); } + +// Main HomePage component wrapped with FileSelectionProvider +export default function HomePage() { + return ( + + + + ); +} diff --git a/frontend/src/services/zipFileService.ts b/frontend/src/services/zipFileService.ts index 3c238e159..90f5b2574 100644 --- a/frontend/src/services/zipFileService.ts +++ b/frontend/src/services/zipFileService.ts @@ -103,6 +103,37 @@ export class ZipFileService { } } + /** + * Create a ZIP file from an array of files + */ + async createZipFromFiles(files: File[], zipFilename: string): Promise<{ zipFile: File; size: number }> { + try { + const zip = new JSZip(); + + // Add each file to the ZIP + for (const file of files) { + const content = await file.arrayBuffer(); + zip.file(file.name, content); + } + + // Generate ZIP blob + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }); + + const zipFile = new File([zipBlob], zipFilename, { + type: 'application/zip', + lastModified: Date.now() + }); + + return { zipFile, size: zipFile.size }; + } catch (error) { + throw new Error(`Failed to create ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + /** * Extract PDF files from a ZIP archive */ diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index b06945610..cc0cd5cbc 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; +import { useToolFileSelection } from "../contexts/FileSelectionContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; @@ -15,15 +16,12 @@ import CompressSettings from "../components/tools/compress/CompressSettings"; import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters"; import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation"; +import { BaseToolProps } from "../types/tool"; -interface CompressProps { - selectedFiles?: File[]; - onPreviewFile?: (file: File | null) => void; -} - -const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => { +const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { setCurrentMode } = useFileContext(); + const { selectedFiles } = useToolFileSelection(); const compressParams = useCompressParameters(); const compressOperation = useCompressOperation(); @@ -37,10 +35,19 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => { }, [compressParams.parameters, selectedFiles]); const handleCompress = async () => { - await compressOperation.executeOperation( - compressParams.parameters, - selectedFiles - ); + try { + await compressOperation.executeOperation( + compressParams.parameters, + selectedFiles + ); + if (compressOperation.files && onComplete) { + onComplete(compressOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : 'Compress operation failed'); + } + } }; const handleThumbnailClick = (file: File) => { @@ -56,7 +63,7 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => { }; const hasFiles = selectedFiles.length > 0; - const hasResults = compressOperation.downloadUrl !== null; + const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null; const filesCollapsed = hasFiles; const settingsCollapsed = hasResults; @@ -77,7 +84,11 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => { isVisible={true} isCollapsed={filesCollapsed} isCompleted={filesCollapsed} - completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined} + completedMessage={hasFiles ? + selectedFiles.length === 1 + ? `Selected: ${selectedFiles[0].name}` + : `Selected: ${selectedFiles.length} files` + : undefined} > void; -} - -const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => { +const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { setCurrentMode } = useFileContext(); + const { selectedFiles } = useToolFileSelection(); const splitParams = useSplitParameters(); const splitOperation = useSplitOperation(); @@ -39,11 +36,20 @@ const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => { }, [splitParams.mode, splitParams.parameters, selectedFiles]); const handleSplit = async () => { - await splitOperation.executeOperation( - splitParams.mode, - splitParams.parameters, - selectedFiles - ); + try { + await splitOperation.executeOperation( + splitParams.mode, + splitParams.parameters, + selectedFiles + ); + if (splitOperation.files && onComplete) { + onComplete(splitOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : 'Split operation failed'); + } + } }; const handleThumbnailClick = (file: File) => { diff --git a/frontend/src/types/pageEditor.ts b/frontend/src/types/pageEditor.ts index 7e0dda16e..f5529aee3 100644 --- a/frontend/src/types/pageEditor.ts +++ b/frontend/src/types/pageEditor.ts @@ -36,3 +36,19 @@ export interface UndoRedoState { operations: PageOperation[]; currentIndex: number; } + +export interface PageEditorFunctions { + closePdf: () => void; + handleUndo: () => void; + handleRedo: () => void; + canUndo: boolean; + canRedo: boolean; + handleRotate: () => void; + handleDelete: () => void; + handleSplit: () => void; + onExportSelected: () => void; + onExportAll: () => void; + exportLoading: boolean; + selectionMode: boolean; + selectedPages: number[]; +} diff --git a/frontend/src/types/tool.ts b/frontend/src/types/tool.ts new file mode 100644 index 000000000..731f0b90e --- /dev/null +++ b/frontend/src/types/tool.ts @@ -0,0 +1,73 @@ +import React from 'react'; + +export type MaxFiles = number; // 1=single, >1=limited, -1=unlimited +export type ToolCategory = 'manipulation' | 'conversion' | 'analysis' | 'utility' | 'optimization' | 'security'; +export type ToolDefinition = Omit; +export type ToolStepType = 'files' | 'settings' | 'results'; + +export interface BaseToolProps { + onComplete?: (results: File[]) => void; + onError?: (error: string) => void; + onPreviewFile?: (file: File | null) => void; +} + +export interface ToolStepConfig { + type: ToolStepType; + title: string; + isVisible: boolean; + isCompleted: boolean; + isCollapsed?: boolean; + completedMessage?: string; + onCollapsedClick?: () => void; +} + +export interface ToolValidationResult { + valid: boolean; + errors?: string[]; + warnings?: string[]; +} + +export interface ToolResult { + success: boolean; + files?: File[]; + error?: string; + downloadUrl?: string; + metadata?: Record; +} + +export interface Tool { + id: string; + name: string; + icon: React.ReactNode; + component: React.ComponentType; + maxFiles: MaxFiles; + category?: ToolCategory; + description?: string; + endpoints?: string[]; + supportedFormats?: string[]; + validation?: (files: File[]) => ToolValidationResult; +} + +export type ToolRegistry = Record; + +export interface FileSelectionState { + selectedFiles: File[]; + maxFiles: MaxFiles; + isToolMode: boolean; +} + +export interface FileSelectionActions { + setSelectedFiles: (files: File[]) => void; + setMaxFiles: (maxFiles: MaxFiles) => void; + setIsToolMode: (isToolMode: boolean) => void; + clearSelection: () => void; +} + +export interface FileSelectionComputed { + canSelectMore: boolean; + isAtLimit: boolean; + selectionCount: number; + isMultiFileMode: boolean; +} + +export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {} \ No newline at end of file diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index f9d94eecc..bff3f5b1c 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -1,6 +1,10 @@ import { FileWithUrl } from "../types/file"; import { StoredFile, fileStorage } from "../services/fileStorage"; +export function getFileId(file: File): string { + return (file as File & { id?: string }).id || file.name; +} + /** * Consolidated file size formatting utility */ From 5f7e578ff88ace4aed4a57fad6a1e6005ef0b29b Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:14:37 +0100 Subject: [PATCH 02/29] change logos, favicon and browser tab title (#4032) # Description of Changes - changed favicon and other logos in public folder - changes the title title tag in the index.html to "Stirling PDF" --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/index.html | 4 ++-- frontend/public/favicon.ico | Bin 3870 -> 15406 bytes frontend/public/logo192.png | Bin 5347 -> 3161 bytes frontend/public/logo512.png | Bin 9664 -> 8151 bytes 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 0fc165c66..c4a808349 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,12 +7,12 @@ - Vite App + Stirling PDF diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index a11777cc471a4344702741ab1c8a588998b1311a..6d6c8521c12d1770066f2d3778a514492be5f8f0 100644 GIT binary patch literal 15406 zcmeHOYit!o6kei<{xbT*|NPOYzi5%-6RY?LQUQ%<6fvM^)r3H-4@A*mjE@4hrMxS& z6k4EA1X?NrLQ%p?XnCoKwADfd5hzHZQ2IEYZ?>~{?%tijxzxCaqiqDDlh+0(1S-=*#Ua<%{9dGw0u;0@aX91q`iCpuy}yar=NaH z{rgXMQ)pF@XEQb|d*#X~1w_65?36D#TR-+gx;kBcfxf6($(?Kqd+(j$C?hj-1ub8` zk6wPc)bYEQO!d3dIEQ@9ahTiWe4x@mKIU4y6DCwftc_|tfB9$|lkzb^o<4nnMvq<= zp{FVz7^Y0AqI>RfZYzHn!flWGs(fH*ZEd5EKKjPOV3!XJ`T08qT|DyaG_Y+RR(V#)*ny$)l%lnlN40!76BlmH33`>{prRSe7_T3k;$_IwO{%WSRYk$`E zM_Ji^8aHm0uW^aFANw)1Z!Ax9bE}xQ?fT0yFvv52Tl>pz@4ma1T3W8e0)hACjjnAl zV1RiBkuto}5hF@?k9^)YhM?sG-=mMtqy77j`@)b6`M_}3U3pYd@slTpnwntSU+o`W zs|j;TdwYi#$Mosn)6}U|j$;7hFV_B7<;y(msJZ#FBZkjE-z4?}@4Z(Ug&`>Uz=3t{ z=+U!L7$9Hb95bd=?2}ac3qpU%|DlKG(5_t#5g1hYsQ2WPg|v9_9?=$h{iF9lQifNW zmX=GUrTch)V8XD=2R(4knX{eG`cD0ColC5C^O9-*j&b?fXPfBCm3DHHkNU75_z3Lt zANHTv1KWAVQ^vk%!i2S2Jv94zaUNrNJ#hZW5$6w_!+P@kCC`Dt&`syCsPm`9AkSk? z=aP8FM3}}L=NN0c^S8wCUrXU9VHd42ieJBe60Rzq^z_2(`}CQ9OKR%0OzyJ^Ztq4; z+rsT0dA17r_byqGS;6nCz%po1-gOe6KBZo%sX4dvJB2aT^Ba$!SUcCmsVDoLLv{RS zr=(21!!W3q4nLo5`fZYb#M@Jv^`vVWm5bJ#~6(nvs|2W{K&)k7;y;15xnA1e)##Q!}F3gzelwv_I68_)KGQx zG0MnT5cS>8ANY%ls|miMQ0TkB{APAJYMIBqSew+}WFGi|1y4gm6TR}va^Yu_-|eZy zFV7W-TRri_0`@KVAA@$DKFZp4RmBe-a4!1z;|*H>mmNR!C@A=WUV3R6rKLH?@OqNX#rAcO9haWn?*RXBdZ}j4eODx}+8-8gM@X2E?;C_$LJsEnq;fD@*8XC^> z*jO#%I)3outUPbt4sm`@24wGbP%C>e(;d>o5YN<9hui}U| z?-xIGfIoBf>Vx8S!>xXUU1w#LYvW<@;+Ax=;D-*diN?lrR9UI_H>>!OhmYoqFE+Cd zvH4#mMz=Ip{LleUQ`1G-xbZN3^;P%|h8?%PJZsiAjtR~WbghdQKXiZ}5j3i@gCUqh7E_KeAjmDvJ4$Q`D7#S-6Pj{wSB$vLE$(1 zp_9&^zf4=U)cV#z+QhJ71#zv_SmWV)t*t$6`JsU&kq7sqcJ6G5X>NM|{jarqUavlY z4$nTjm^N)X!uz)NM4(%=n48wEJLJ0`62UL^8#r(-VaVSR!kt5D`FTM2XY5dYAu;0fS2J37r z&((q-dLV{ZRJ4aKUTpQP0_5_T()k~2xXD*63 zFZ>ef9owG5FZFrjjg|EK?@hecx!(@~eypj8J7T{HpPddneouD$OZ>otJ9WEvA9d{l zi63-WABPStV1L~f5l42?qr31+Jg{r{S9JGuRQyu+^z_*@dGZF~XR+(ho%kgl*hFFB z55nJN?+hpWpu=5ntSwmQRUN$Wb02Dba1u{EDRjUd_|HEh?+NMfLk8|K!q2MOgd6@Y z?2Yl@^FxF6di3bhi2pNi!w;UYqoU&_J% zALQk2CEVFTEJFUSrriDmnwc v^m)n0cmjrc*r+*X!uJO3=w%spA!Ea=M-ShhnU*Q%8Hw-z^#2wJss;W5j>QEm literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..2994ca293ac701349f32ab1450e74a0d29aa7233 100644 GIT binary patch literal 3161 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 literal 5347 zcmZWtbyO6NvR-oO24RV%BvuJ&=?+<7=`LvyB&A_#M7mSDYw1v6DJkiYl9XjT!%$dLEBTQ8R9|wd3008in6lFF3GV-6mLi?MoP_y~}QUnaDCHI#t z7w^m$@6DI)|C8_jrT?q=f8D?0AM?L)Z}xAo^e^W>t$*Y0KlT5=@bBjT9kxb%-KNdk zeOS1tKO#ChhG7%{ApNBzE2ZVNcxbrin#E1TiAw#BlUhXllzhN$qWez5l;h+t^q#Eav8PhR2|T}y5kkflaK`ba-eoE+Z2q@o6P$)=&` z+(8}+-McnNO>e#$Rr{32ngsZIAX>GH??tqgwUuUz6kjns|LjsB37zUEWd|(&O!)DY zQLrq%Y>)Y8G`yYbYCx&aVHi@-vZ3|ebG!f$sTQqMgi0hWRJ^Wc+Ibv!udh_r%2|U) zPi|E^PK?UE!>_4`f`1k4hqqj_$+d!EB_#IYt;f9)fBOumGNyglU(ofY`yHq4Y?B%- zp&G!MRY<~ajTgIHErMe(Z8JG*;D-PJhd@RX@QatggM7+G(Lz8eZ;73)72Hfx5KDOE zkT(m}i2;@X2AT5fW?qVp?@WgN$aT+f_6eo?IsLh;jscNRp|8H}Z9p_UBO^SJXpZew zEK8fz|0Th%(Wr|KZBGTM4yxkA5CFdAj8=QSrT$fKW#tweUFqr0TZ9D~a5lF{)%-tTGMK^2tz(y2v$i%V8XAxIywrZCp=)83p(zIk6@S5AWl|Oa2hF`~~^W zI;KeOSkw1O#TiQ8;U7OPXjZM|KrnN}9arP)m0v$c|L)lF`j_rpG(zW1Qjv$=^|p*f z>)Na{D&>n`jOWMwB^TM}slgTEcjxTlUby89j1)|6ydRfWERn3|7Zd2&e7?!K&5G$x z`5U3uFtn4~SZq|LjFVrz$3iln-+ucY4q$BC{CSm7Xe5c1J<=%Oagztj{ifpaZk_bQ z9Sb-LaQMKp-qJA*bP6DzgE3`}*i1o3GKmo2pn@dj0;He}F=BgINo};6gQF8!n0ULZ zL>kC0nPSFzlcB7p41doao2F7%6IUTi_+!L`MM4o*#Y#0v~WiO8uSeAUNp=vA2KaR&=jNR2iVwG>7t%sG2x_~yXzY)7K& zk3p+O0AFZ1eu^T3s};B%6TpJ6h-Y%B^*zT&SN7C=N;g|#dGIVMSOru3iv^SvO>h4M=t-N1GSLLDqVTcgurco6)3&XpU!FP6Hlrmj}f$ zp95;b)>M~`kxuZF3r~a!rMf4|&1=uMG$;h^g=Kl;H&Np-(pFT9FF@++MMEx3RBsK?AU0fPk-#mdR)Wdkj)`>ZMl#^<80kM87VvsI3r_c@_vX=fdQ`_9-d(xiI z4K;1y1TiPj_RPh*SpDI7U~^QQ?%0&!$Sh#?x_@;ag)P}ZkAik{_WPB4rHyW#%>|Gs zdbhyt=qQPA7`?h2_8T;-E6HI#im9K>au*(j4;kzwMSLgo6u*}-K`$_Gzgu&XE)udQ zmQ72^eZd|vzI)~!20JV-v-T|<4@7ruqrj|o4=JJPlybwMg;M$Ud7>h6g()CT@wXm` zbq=A(t;RJ^{Xxi*Ff~!|3!-l_PS{AyNAU~t{h;(N(PXMEf^R(B+ZVX3 z8y0;0A8hJYp@g+c*`>eTA|3Tgv9U8#BDTO9@a@gVMDxr(fVaEqL1tl?md{v^j8aUv zm&%PX4^|rX|?E4^CkplWWNv*OKM>DxPa z!RJ)U^0-WJMi)Ksc!^ixOtw^egoAZZ2Cg;X7(5xZG7yL_;UJ#yp*ZD-;I^Z9qkP`} zwCTs0*%rIVF1sgLervtnUo&brwz?6?PXRuOCS*JI-WL6GKy7-~yi0giTEMmDs_-UX zo=+nFrW_EfTg>oY72_4Z0*uG>MnXP=c0VpT&*|rvv1iStW;*^={rP1y?Hv+6R6bxFMkxpWkJ>m7Ba{>zc_q zEefC3jsXdyS5??Mz7IET$Kft|EMNJIv7Ny8ZOcKnzf`K5Cd)&`-fTY#W&jnV0l2vt z?Gqhic}l}mCv1yUEy$%DP}4AN;36$=7aNI^*AzV(eYGeJ(Px-j<^gSDp5dBAv2#?; zcMXv#aj>%;MiG^q^$0MSg-(uTl!xm49dH!{X0){Ew7ThWV~Gtj7h%ZD zVN-R-^7Cf0VH!8O)uUHPL2mO2tmE*cecwQv_5CzWeh)ykX8r5Hi`ehYo)d{Jnh&3p z9ndXT$OW51#H5cFKa76c<%nNkP~FU93b5h-|Cb}ScHs@4Q#|}byWg;KDMJ#|l zE=MKD*F@HDBcX@~QJH%56eh~jfPO-uKm}~t7VkHxHT;)4sd+?Wc4* z>CyR*{w@4(gnYRdFq=^(#-ytb^5ESD?x<0Skhb%Pt?npNW1m+Nv`tr9+qN<3H1f<% zZvNEqyK5FgPsQ`QIu9P0x_}wJR~^CotL|n zk?dn;tLRw9jJTur4uWoX6iMm914f0AJfB@C74a;_qRrAP4E7l890P&{v<}>_&GLrW z)klculcg`?zJO~4;BBAa=POU%aN|pmZJn2{hA!d!*lwO%YSIzv8bTJ}=nhC^n}g(ld^rn#kq9Z3)z`k9lvV>y#!F4e{5c$tnr9M{V)0m(Z< z#88vX6-AW7T2UUwW`g<;8I$Jb!R%z@rCcGT)-2k7&x9kZZT66}Ztid~6t0jKb&9mm zpa}LCb`bz`{MzpZR#E*QuBiZXI#<`5qxx=&LMr-UUf~@dRk}YI2hbMsAMWOmDzYtm zjof16D=mc`^B$+_bCG$$@R0t;e?~UkF?7<(vkb70*EQB1rfUWXh$j)R2)+dNAH5%R zEBs^?N;UMdy}V};59Gu#0$q53$}|+q7CIGg_w_WlvE}AdqoS<7DY1LWS9?TrfmcvT zaypmplwn=P4;a8-%l^e?f`OpGb}%(_mFsL&GywhyN(-VROj`4~V~9bGv%UhcA|YW% zs{;nh@aDX11y^HOFXB$a7#Sr3cEtNd4eLm@Y#fc&j)TGvbbMwze zXtekX_wJqxe4NhuW$r}cNy|L{V=t#$%SuWEW)YZTH|!iT79k#?632OFse{+BT_gau zJwQcbH{b}dzKO?^dV&3nTILYlGw{27UJ72ZN){BILd_HV_s$WfI2DC<9LIHFmtyw? zQ;?MuK7g%Ym+4e^W#5}WDLpko%jPOC=aN)3!=8)s#Rnercak&b3ESRX3z{xfKBF8L z5%CGkFmGO@x?_mPGlpEej!3!AMddChabyf~nJNZxx!D&{@xEb!TDyvqSj%Y5@A{}9 zRzoBn0?x}=krh{ok3Nn%e)#~uh;6jpezhA)ySb^b#E>73e*frBFu6IZ^D7Ii&rsiU z%jzygxT-n*joJpY4o&8UXr2s%j^Q{?e-voloX`4DQyEK+DmrZh8A$)iWL#NO9+Y@!sO2f@rI!@jN@>HOA< z?q2l{^%mY*PNx2FoX+A7X3N}(RV$B`g&N=e0uvAvEN1W^{*W?zT1i#fxuw10%~))J zjx#gxoVlXREWZf4hRkgdHx5V_S*;p-y%JtGgQ4}lnA~MBz-AFdxUxU1RIT$`sal|X zPB6sEVRjGbXIP0U+?rT|y5+ev&OMX*5C$n2SBPZr`jqzrmpVrNciR0e*Wm?fK6DY& zl(XQZ60yWXV-|Ps!A{EF;=_z(YAF=T(-MkJXUoX zI{UMQDAV2}Ya?EisdEW;@pE6dt;j0fg5oT2dxCi{wqWJ<)|SR6fxX~5CzblPGr8cb zUBVJ2CQd~3L?7yfTpLNbt)He1D>*KXI^GK%<`bq^cUq$Q@uJifG>p3LU(!H=C)aEL zenk7pVg}0{dKU}&l)Y2Y2eFMdS(JS0}oZUuVaf2+K*YFNGHB`^YGcIpnBlMhO7d4@vV zv(@N}(k#REdul8~fP+^F@ky*wt@~&|(&&meNO>rKDEnB{ykAZ}k>e@lad7to>Ao$B zz<1(L=#J*u4_LB=8w+*{KFK^u00NAmeNN7pr+Pf+N*Zl^dO{LM-hMHyP6N!~`24jd zXYP|Ze;dRXKdF2iJG$U{k=S86l@pytLx}$JFFs8e)*Vi?aVBtGJ3JZUj!~c{(rw5>vuRF$`^p!P8w1B=O!skwkO5yd4_XuG^QVF z`-r5K7(IPSiKQ2|U9+`@Js!g6sfJwAHVd|s?|mnC*q zp|B|z)(8+mxXyxQ{8Pg3F4|tdpgZZSoU4P&9I8)nHo1@)9_9u&NcT^FI)6|hsAZFk zZ+arl&@*>RXBf-OZxhZerOr&dN5LW9@gV=oGFbK*J+m#R-|e6(Loz(;g@T^*oO)0R zN`N=X46b{7yk5FZGr#5&n1!-@j@g02g|X>MOpF3#IjZ_4wg{dX+G9eqS+Es9@6nC7 zD9$NuVJI}6ZlwtUm5cCAiYv0(Yi{%eH+}t)!E^>^KxB5^L~a`4%1~5q6h>d;paC9c zTj0wTCKrhWf+F#5>EgX`sl%POl?oyCq0(w0xoL?L%)|Q7d|Hl92rUYAU#lc**I&^6p=4lNQPa0 znQ|A~i0ip@`B=FW-Q;zh?-wF;Wl5!+q3GXDu-x&}$gUO)NoO7^$BeEIrd~1Dh{Tr` z8s<(Bn@gZ(mkIGnmYh_ehXnq78QL$pNDi)|QcT*|GtS%nz1uKE+E{7jdEBp%h0}%r zD2|KmYGiPa4;md-t_m5YDz#c*oV_FqXd85d@eub?9N61QuYcb3CnVWpM(D-^|CmkL z(F}L&N7qhL2PCq)fRh}XO@U`Yn<?TNGR4L(mF7#4u29{i~@k;pLsgl({YW5`Mo+p=zZn3L*4{JU;++dG9 X@eDJUQo;Ye2mwlRs_16?f1N8_xDGi&U@bTp7WgN`aNgPxVt(`7^giB046xDS-B2? zhD{o%jKzO_q3?zP7%7e`Z9PMYjs}-Mli!D#f6K|$Ne?+6kxX1X(7)aFc=H-Blj*Tr zJ9W=lf3n{+?$>o!JTK_R50q)!DHrNZygzZR%F~UBcJKc9!QI5iaLibnHvNR2fL}Z} z%{2IOeop&`+dJ3P-f)ciSX4f+G>%zT6VXxFC~oK~=`zx(t6ohz@mPM~u3~$+alrdZ zBiEUZ3`zCY+L|qOCb2mIYjZC#qx2J*DUqBnW|}5<4WPF9?Jeor2=6qHNPc1b+X&Gc zzvbsj>bSCw#zThpw9}7WojVXSF?p`mu*MfVjzj*7)XYq!zMt}24_^ALd;CdFcj)5_ z@?NGDqptpO!dO2G!YBI^w?sYN?5xawS_J%c$oO=I*4r_v4l1`k(6*Nu@w-O z?2zWad64kCKYz@zjml(0c_%X~qC_2%D^BVjHqLoh#qQt4$&hvq?XFU1iLV>47ubt+ zmJfU`Z7I*~nRo0%G9(np2e|_eL~ID(8R0qgW^J&xh3mYYzHK4a9>1*i-y$ViFXRLs zjIjEU_ioPJCB*5A-b0L*I+HkO;PJN}(a!AAAoIR*w{fxul`1${pQFCsF6i7EmLKJ7 z1ln>KU*l&vJlttCX5+%RPy<46feFrkwy))Nn~|Zf%c-Ylrv_g&YaE_%z!DO~GVavN z=BbWj#UMyY?4xN%sQC4C$NV-JPJ=`1KE7mr6J+gbOYd#r$7@%tfuzVHzVQbQuz3CQ zh8?Se;CV??=W8jLYk(EE+KzQMm1eA^@J~3aNK=I*r}0z6+h1GpukT-1rz+^o;+GF9 z33j?B7!eM_c=GbP3Hw!{Q%dlrh~FL6aSwycK_yoD{x-U&HZ*NhcuwUc0cn~n zDc+w{+q-_1sTuLyY;}RTlz_$6Q9p+2ZQxymOvyve<8Qf_fPCa-YA;CM@M_}bVUtfu z|LpBnCRfjNOnZ}Sq;d4}1L<6{d+^L|s&ncB=PqGwK|=akvLKz`PY27k6iJ+pwn`g>tT)+0e3SaG&7f$H!c!u3Oe5~p<-Ivo63EzN5+shJ0E?0)2zpV3e#0< zjY=1X-&1{DvkA6Kl*o-|-Nr=z@pxQbLhrR+H&`*K1P6n6rzjuKc7>@5;LQkjBOdOR z6+PA<$ED1qgSLfsl-}3e*TWNn6;Ls6>8|L#sZcFbV&(p_dQny9Z6l!iUGBRQH%9xd zUN0H5XGU;0P1D9dlS~YPztn#?-46MD0M!?^c(#ojmsv4L8 zAEnCV_9cN8CW0cu$7?EAA9%gO#j>*!nbgIA^@Rzomt{iIDjBAVy1hhnf()+f(*E?f z8L1RE4#-2=9Y=CF+rXHsaMPy-U&{$R#|-Om^U?yL-tl-6_x9E-$Tvpdvb^*wbrWwSnT1oeNIQ)Y8W@}hG=jeUT zVwk$>GaX>K{pxG^?@E)RX|Zow9dQ1f!dZeu4f1JBhn(=4?)uy+9y0IWKeCYM6L6Tp zbED*94wI0e7L2aRpLV-kp>-NhVdXaTTq*xuIW^ogs_%)iYIUPuZm-o+!Z>#4JR+#N z!+X#!e9%q4JCFv>M;#)Mgo@3BfGm5|62h^PrS_=3m~w=On;A#2;uOc`4-XpITyVW4 zmSstl|IQ8`?D^IHfG9bcUSSaMjvbg92O*zxU!j9IZD!j~zQWhNze=Zy#7ace*) zpv7M?&|JjCd5V8AGy;=`Ka?QoST@5m%}b&v`RqB>VG8SP@gar0zdxw%_IlM@tS}iT zuU@7GtOLc?{Bp7Vm$+y4EE-g-ZPiuF$ClWVYmRDuSw*I!12JULU%)nEL6C3j4LaY90*^>tfUQqPG~uC_*tV zJF*+}0jw6hq!v7S4ZzDNP9m!IuHo);?)hp%K;BvuAWOte=9ff0ase~`XIIEB?GXMclS{Yp8M@cp82qbYP zO}?bZ>LqszuENAmDKi)jO^5+dLid;$FFl15q^uv%i*aN>)dLpSL~=oIR5Jg~bZDK8 z`$%uM@}fcCFOJmqS2@GsmFk%{i*u3<`NfNgg5vzr(TRMb1s`vsR9)XC`Eral`Xzk z84hYo%wICjTH#@Q*r^G%SU4H%`9fVT>p zlM7_k{Ux1@X$HV@st^9h^ARU%TMhSig6vi-z`RcZj6h?FhXDZ_U*L4bBAZlseG9() z1;A|QMMge0qp|68k+F1VE=Llre&eS&hMW6h0<@=<$O76k+DmY=9$09!e;-k;-*F^P z_W{cImZ;^cD0ito^Qu0u&Qanx$O4)f4vLDY1nkH8mnO(IYhsg{zv+Z=_-rBjPnRH(QE=#K^0l~$Gn6t$B9q(?_D~%;@lxd)kS;eh}TQmu#uRz;O z7E9ku@EAxlr^DFSsAnAHK!a$s)6ympFRORcK$<_kGKw1(y=c^D?!kz$(SYewF%zJL zC#k^rQ`wBA&ON(~ag^zmgF)A zeI9)o?55l6LB(?Hdn&RSpA4*wB+M!CQWo%xcIGk)T+pjx2hq&XdsLZ}eRjQw4UTKs;|c>{*B783cJ71#LAV zNF@^q(RUfsAc{8Z0-u&;)$ut|)(sedG#;N0_j>Zw1&5QyzBqw0J#C@`iZpf&OkaZk zPdUQ$TUcybB%)RZ`FS$Fs8I(4!7+~kCEX>&^hv%<7*GKv6jhKzCsR-)Qlte8z z$RSl&>^=}3ZX$WoLnmX4j{i(r*!s*Qn(DB#=&yGm+R9CdhcpXe`XAJe#aK zj-tP+(xIi?h^I{xT>L(30Ww3U|ojLSb-N=O_mbqIKKDOxFiJeqgjlR0F z#(evL5=Ow>R-}h{a`*Vq`W9~?K;^R%+mp|VvBn;F=AnrAazVkrJ}=V&k(YmH49`aXPBWKInR2qG^elVw2#Z{ z;z-OD3uS|Uom{yXI}I>NY??cqHRzy%TY?I7xfe&}?KKbjg=RwbVuTqAaOV5+!%>o5 zY|mNZl9q>9?hU`YQ9ts~YRtCwH5nAGn4vHxlEqHnQHB%Qk<(XJYHoz#3prQ^sy60- zi>SXDhC?y#UQG7o=cYy+S~oGB>Y?h^WTTb-@}bm%F7MfhM;H&yv7=S+{#F`fTH=WG z_NrOfOh`>wF83RbsE^r_Agf&kFzp6b9HbNkqm!co72^Kp2b3%1Uo)_*qiMeZ1(#6uC!n5DA{Vk7=8PhY7d8z5syqfL zzA7&q-1%s*Z26f(IQ1Q@Y@Y*C*{HjhR3#W{l@8G>pQ(DZOXtZOj6fg=tB*kt=s$}D zf?=AET*5#u!AQmYUgb@L@#j$__kpiiN5|^N4L6!`r7k_YE>(Azlr@TMD9B;q4xMWJ zr_*6NjHsN#8EZ_|6wWvRIqN&lV4+o~?1#O`3n^GhaN!BaUe;8({hsU>QD{S{+I?Ub zBB72}fy`L4t;f@Bk+I}Ia1pZW*8mx}4r_TfLoyk7(M^Sc!nxu0usajw|*C2UdNY62nP*8FmrU(|B%Iz9fT7t z8dY%X6asDxwnnQP7O;Jpd(Kd~6@kL+xoUM)amYul|X6Y4^ST;x8baonN8K#(a zk=Ibn&~t{&W0=cOEO}zP^9fh6Y0g}`Dit^!y-6|1tqdtOPr^hVgv3;^Ggt9;IDA-s zWs&NQGu-IsWsOa4msR2Xy#!fP5wj=;aejntx@1+}ZZ{d6`Y7fiv}!7XOYKjbZmsxi zhV!nAj3|Au*qrqZy>(xLtksMlpNxPIjTNf%cvg+{as$ITA+n1S#^wf*XYH}sdqWIYigq`anp7058pOVo5 z1D_pY-aB=D(vyB99*QLqiT;J3E6vK4st|Mv<7web^*u;Obc_HrUGrq{v&LY3_Wd+x zaSd2^M!h{L6?$v0Z$y({P z2HZjxKx_Hr(7{w=xx9L4G*$bc%8;uZ;__O!A-eb;pD&H1Zl<3WA{Hv--r1jiYO8On z6tXMk&_E5-2}WLp>j-a+EL@-puPDyMGT&hY=cy)Yy+Ic&(6#cL@|bb5L>Uir`wdDD zI1MtKGR7HNUZl`8T1h=isJJVMqHyjWM*rmxRh8oM>y8#KD_UkO=bU8*#e-nOEb(k= z=_nd#j2;MDF@c~Wr;OCCGJR&ND&g~q3dd|l9~Ug#NBB6PPrdTlcX(ir0NaQV-ZX(= zByFoB15TfW5ss&o>0=2W)BnbNY`2&*48{7qUt8VzgpxNmNsjca8cf7ic9$iUScrC*!EIBKr89*+we`vk2bP z(M2;E1TT7YC4nuD8qJdg9zk`mLSSi9LTFq@9hv{#n#DP&AW%&E8gvAHad9<{ZYS9wjttoaabmy{1g&$Yxd2$*Y()?|rYVtav)9J!&F6hw^s*d%#>( zqrTaI;Mw6;Pe;f@mF-5K5mhl{lxpo@^F{Ri|7+pFamb{jeJL3Z(#bJVoL##Dn@11F z#6eBMr$D;Z=~eLs=6&?w2h|2f;I0%eB`;m01INzx8uI7Gd3C8ISSmwnzW8T`BAODWd2>BqgVgE5W9Qmi5lhrH%bfqT;S z){}JTt6IECzDbFd6@k}8eecT3-oKW=2aAt2VR$zb72K=Js#i@2QNk%&lQ)Y>bJ!7G z86{QnrZT_2Wc<3UY{E7^-qwEHt3;6O{D5=(ed9k>bw2KG=qbP))MuAp?av(e@Kfre zftKf1@*6uXhxgDRJgvGVO>Q-Y>^c3Z21h;j+uQQhN?z|m=xQIgnE)U=yi39<59K#L z7HwcbYs_!e;{BXP$c&h9*o1)O#U_e_hinQOGdgBKlLv37Ww;>$4U4r8&g^p%DRFFn zn1ol_ds|*cv)cr~8?QpWJD1XsAT}Y0Q_?5@o71+Cf=6z z_2pMg1Z%th@$T$o&J@bdnQzPcR;e5}OU7NeeZ_eoH~yemyz5O}sa&?t)vYnC<;t9N zyzKTn`SO~=zp(#ym^`K3jJzW}ffw2X#1D>D zLpG^@TjCE1DLtm2?&2R-+>JXgq$#X>y@_^{*hXg?O}s2x5W6_^75sz|S-Vo_azfo`aMfdK`HG_a{FF*#l+{64ZM+U$@R$(0(kHx8f7E-+@LPJ7>4DhD?s7Y0AeXe}(ZGAZL zvf5|862Y5RoOqLMRuJg@2R$_H@_euA$6c*&Jlb~Cz;rTKhRY4b+_p;`OMdG)sW!t7 zz;jnfx3`<-ofU9Gg|L~W0;RVPbgQc|$6Oa32>Cel^H75x{*>hA5!rj^+BNQttM3A7 znJwvRS}7qqc&TI>yH{AOP^4?W*M{S+0L^vQ1Bz(R`?X4}Ni^_zxU5U+|85PaOs36R z65eP3G#Wnb`O>8L64-@oRrfk3t*s_EI8h4jdRHT&if0zpSy|2ZxBkL#kf)#2Ci z!~tKGC3ib%BA?HlFnHsI)L_=WXB2hw0$6p=_ z+hsa%FGq6wn1fLF@Ir!B92_Ds|J_VYk^U~AP!%E!Z_-5Fn^v#tUUicObJM)^b4wfK zRs}_Mjc#2uL5^WsjA*5g~))oC$8 zPTTTv{%Q!h?LDPApHIFibA9|y9Lb0i9~o~5^|!{;7esw~Ru&qW?Hm@8AYOLBj$rYN zcY3Fc^Y8pUC^*vhN#k_kaZcKkv1D}n_8ET`Z(1-bBT;ee(7dO%Cr;k+KKZ5??k?O? yosnlbq|e5fhfQKXu5^)&uq500x`SKhPpIsQLW+a)mo literal 9664 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN From a9a41b3877a55412575453db4fa294436ca7d6e6 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:27:30 +0100 Subject: [PATCH 03/29] V2: Design Navbar (#4017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - Changed the navbar styling to include all the icons that were on our figma design - Added a new link to our index.html to include the MUI symbols. - I chose to keep the automate and read icons the same as they were already because I feel as though they make more sense ``` ``` --- figma vs app dark Screenshot 2025-07-21 at 5 44 19 PM figma vs app light Screenshot 2025-07-21 at 5 57 27 PM ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [x] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/package-lock.json | 7 + frontend/package.json | 1 + .../src/components/shared/QuickAccessBar.css | 179 ++++++++++ .../src/components/shared/QuickAccessBar.tsx | 326 +++++++++++++++--- frontend/src/global.d.ts | 1 + frontend/src/hooks/useIsOverflowing.ts | 73 ++++ frontend/src/index.css | 6 + frontend/src/pages/HomePage.tsx | 12 +- frontend/src/styles/theme.css | 106 ++++-- 9 files changed, 632 insertions(+), 79 deletions(-) create mode 100644 frontend/src/components/shared/QuickAccessBar.css create mode 100644 frontend/src/hooks/useIsOverflowing.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 60c2af7e4..2124a2b1f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", "jszip": "^3.10.1", + "material-symbols": "^0.33.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", @@ -4175,6 +4176,12 @@ "semver": "bin/semver.js" } }, + "node_modules/material-symbols": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.33.0.tgz", + "integrity": "sha512-t9/Gz+14fClRgN7oVOt5CBuwsjFLxSNP9BRDyMrI5el3IZNvoD94IDGJha0YYivyAow24rCS0WOkAv4Dp+YjNg==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index aa5251545..4f7284717 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", "jszip": "^3.10.1", + "material-symbols": "^0.33.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", diff --git a/frontend/src/components/shared/QuickAccessBar.css b/frontend/src/components/shared/QuickAccessBar.css new file mode 100644 index 000000000..b1d22fcc3 --- /dev/null +++ b/frontend/src/components/shared/QuickAccessBar.css @@ -0,0 +1,179 @@ +.activeIconScale { + transform: scale(1.3); + transition: transform 0.2s; + z-index: 1; +} + +.iconContainer { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; +} + +/* Action icon styles */ +.action-icon-style { + background-color: var(--icon-user-bg); + color: var(--icon-user-color); + border-radius: 50%; + width: 1.5rem; + height: 1.5rem; +} + +/* Main container styles */ +.quick-access-bar-main { + background-color: var(--bg-muted); + width: 5rem; + min-width: 5rem; + max-width: 5rem; + position: relative; + z-index: 10; +} + +/* Rainbow mode container */ +.quick-access-bar-main.rainbow-mode { + background-color: var(--bg-muted); + width: 5rem; + min-width: 5rem; + max-width: 5rem; + position: relative; + z-index: 10; +} + +/* Header padding */ +.quick-access-header { + padding: 1rem 0.5rem 0.5rem 0.5rem; +} + +.nav-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-bottom: 0; + gap: 0.5rem; +} + +/* Nav header divider */ +.nav-header-divider { + width: 3.75rem; + border-color: var(--color-gray-300); + margin-top: 0.5rem; + margin-bottom: 1rem; +} + +/* All tools text styles */ +.all-tools-text { + margin-top: 0.75rem; + font-size: 0.75rem; + text-rendering: optimizeLegibility; + font-synthesis: none; +} + +.all-tools-text.active { + color: var(--text-primary); + font-weight: bold; +} + +.all-tools-text.inactive { + color: var(--color-gray-700); + font-weight: normal; +} + +/* Overflow divider */ +.overflow-divider { + width: 3.75rem; + border-color: var(--color-gray-300); + margin: 0 0.5rem; +} + +/* Scrollable content area */ +.quick-access-bar { + overflow-x: auto; + overflow-y: auto; + scrollbar-gutter: stable both-edges; + -webkit-overflow-scrolling: touch; + padding: 0 0.5rem 1rem 0.5rem; +} + +/* Scrollable content container */ +.scrollable-content { + display: flex; + flex-direction: column; + height: 100%; + min-height: 100%; +} + +/* Button text styles */ +.button-text { + margin-top: 0.75rem; + font-size: 0.75rem; + text-rendering: optimizeLegibility; + font-synthesis: none; +} + +.button-text.active { + color: var(--text-primary); + font-weight: bold; +} + +.button-text.inactive { + color: var(--color-gray-700); + font-weight: normal; +} + +/* Content divider */ +.content-divider { + width: 3.75rem; + border-color: var(--color-gray-300); +} + +/* Spacer */ +.spacer { + flex: 1; + margin-top: 1rem; +} + +/* Config button text */ +.config-button-text { + margin-top: 0.75rem; + font-size: 0.75rem; + color: var(--color-gray-700); + font-weight: normal; + text-rendering: optimizeLegibility; + font-synthesis: none; +} + +/* Font size utility */ +.font-size-20 { + font-size: 20px; +} + +/* Hide scrollbar by default, show on scroll (Webkit browsers - Chrome, Safari, Edge) */ +.quick-access-bar::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; + background: transparent; +} + +.quick-access-bar:hover::-webkit-scrollbar, +.quick-access-bar:active::-webkit-scrollbar, +.quick-access-bar:focus::-webkit-scrollbar { + background: rgba(0, 0, 0, 0.1); +} + +.quick-access-bar::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 0.25rem; +} + +.quick-access-bar::-webkit-scrollbar-track { + background: transparent; +} + +/* Firefox scrollbar styling */ +.quick-access-bar { + scrollbar-width: auto; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +} \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 45f3b28c7..22a49617e 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -1,11 +1,17 @@ -import React, { useState } from "react"; -import { ActionIcon, Stack, Tooltip } from "@mantine/core"; -import MenuBookIcon from "@mui/icons-material/MenuBook"; -import AppsIcon from "@mui/icons-material/Apps"; -import SettingsIcon from "@mui/icons-material/Settings"; +import React, { useState, useRef } from "react"; +import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core"; +import MenuBookIcon from "@mui/icons-material/MenuBookRounded"; +import AppsIcon from "@mui/icons-material/AppsRounded"; +import SettingsIcon from "@mui/icons-material/SettingsRounded"; +import AutoAwesomeIcon from "@mui/icons-material/AutoAwesomeRounded"; +import FolderIcon from "@mui/icons-material/FolderRounded"; +import PersonIcon from "@mui/icons-material/PersonRounded"; +import NotificationsIcon from "@mui/icons-material/NotificationsRounded"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; import rainbowStyles from '../../styles/rainbow.module.css'; import AppConfigModal from './AppConfigModal'; +import { useIsOverflowing } from '../../hooks/useIsOverflowing'; +import './QuickAccessBar.css'; interface QuickAccessBarProps { onToolsClick: () => void; @@ -16,6 +22,86 @@ interface QuickAccessBarProps { readerMode: boolean; } +interface ButtonConfig { + id: string; + name: string; + icon: React.ReactNode; + tooltip: string; + isRound?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; + onClick: () => void; +} + +function NavHeader({ + activeButton, + setActiveButton, + onReaderToggle, + onToolsClick +}: { + activeButton: string; + setActiveButton: (id: string) => void; + onReaderToggle: () => void; + onToolsClick: () => void; +}) { + return ( + <> +
+ + + + + + + + + + +
+ {/* Divider after top icons */} + + {/* All Tools button below divider */} + +
+ { + setActiveButton('tools'); + onReaderToggle(); + onToolsClick(); + }} + style={{ + backgroundColor: activeButton === 'tools' ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)', + color: activeButton === 'tools' ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)', + border: 'none', + borderRadius: '8px', + }} + className={activeButton === 'tools' ? 'activeIconScale' : ''} + > + + + + + + All Tools + +
+
+ + ); +} + const QuickAccessBar = ({ onToolsClick, onReaderToggle, @@ -26,55 +112,201 @@ const QuickAccessBar = ({ }: QuickAccessBarProps) => { const { isRainbowMode } = useRainbowThemeContext(); const [configModalOpen, setConfigModalOpen] = useState(false); + const [activeButton, setActiveButton] = useState('tools'); + const scrollableRef = useRef(null); + const isOverflow = useIsOverflowing(scrollableRef); + + const buttonConfigs: ButtonConfig[] = [ + { + id: 'read', + name: 'Read', + icon: , + tooltip: 'Read documents', + size: 'lg', + isRound: false, + onClick: () => { + setActiveButton('read'); + onReaderToggle(); + } + }, + { + id: 'sign', + name: 'Sign', + icon: + + signature + , + tooltip: 'Sign your document', + size: 'lg', + isRound: false, + onClick: () => setActiveButton('sign') + }, + { + id: 'automate', + name: 'Automate', + icon: , + tooltip: 'Automate workflows', + size: 'lg', + isRound: false, + onClick: () => setActiveButton('automate') + }, + { + id: 'files', + name: 'Files', + icon: , + tooltip: 'Manage files', + isRound: true, + size: 'lg', + onClick: () => setActiveButton('files') + }, + { + id: 'activity', + name: 'Activity', + icon: + + vital_signs + , + tooltip: 'View activity and analytics', + isRound: true, + size: 'lg', + onClick: () => setActiveButton('activity') + }, + { + id: 'config', + name: 'Config', + icon: , + tooltip: 'Configure settings', + size: 'lg', + onClick: () => { + setConfigModalOpen(true); + } + } + ]; + + const CIRCULAR_BORDER_RADIUS = '50%'; + const ROUND_BORDER_RADIUS = '8px'; + + const getBorderRadius = (config: ButtonConfig): string => { + return config.isRound ? CIRCULAR_BORDER_RADIUS : ROUND_BORDER_RADIUS; + }; + + const getButtonStyle = (config: ButtonConfig) => { + const isActive = activeButton === config.id; + + if (isActive) { + return { + backgroundColor: `var(--icon-${config.id}-bg)`, + color: `var(--icon-${config.id}-color)`, + border: 'none', + borderRadius: getBorderRadius(config), + }; + } + + // Inactive state - use consistent inactive colors + return { + backgroundColor: 'var(--icon-inactive-bg)', + color: 'var(--icon-inactive-color)', + border: 'none', + borderRadius: getBorderRadius(config), + }; + }; return (
- - {/* All Tools Button */} -
- - - - Tools -
+ {/* Fixed header outside scrollable area */} +
+ +
- {/* Reader Mode Button */} -
- - - - Read -
+ {/* Conditional divider when overflowing */} + {isOverflow && ( + + )} - {/* Spacer */} -
- - {/* Config Modal Button (for testing) */} -
- setConfigModalOpen(true)} - > - - - Config + {/* Scrollable content area */} +
{ + // Prevent the wheel event from bubbling up to parent containers + e.stopPropagation(); + }} + > +
+ {/* Top section with main buttons */} + + {buttonConfigs.slice(0, -1).map((config, index) => ( + + +
+ + + {config.icon} + + + + {config.name} + +
+
+ + {/* Add divider after Automate button (index 2) */} + {index === 2 && ( + + )} +
+ ))} +
+ + {/* Spacer to push Config button to bottom */} +
+ + {/* Config button at the bottom */} + +
+ { + setConfigModalOpen(true); + }} + style={{ + backgroundColor: 'var(--icon-inactive-bg)', + color: 'var(--icon-inactive-color)', + border: 'none', + borderRadius: '8px', + }} + > + + + + + + Config + +
+
- +
{ + if (isOverflow) { + // Do something + } + }, [isOverflow]); + + const scrollableRef = useRef(null); + const isOverflow = useIsOverflowing(scrollableRef); + + Fallback example (for browsers without ResizeObserver): + + return ( +
+ {Content that might overflow} +
+ ); +*/ + + +export const useIsOverflowing = (ref: React.RefObject, callback?: (isOverflow: boolean) => void) => { + // State to track overflow status + const [isOverflow, setIsOverflow] = React.useState(undefined); + + React.useLayoutEffect(() => { + const { current } = ref; + + // Function to check if element is overflowing + const trigger = () => { + if (!current) return; + + // Compare scroll height (total content height) vs client height (visible height) + const hasOverflow = current.scrollHeight > current.clientHeight; + setIsOverflow(hasOverflow); + + // Call optional callback with overflow state + if (callback) callback(hasOverflow); + }; + + if (current) { + // Use ResizeObserver for modern browsers (real-time detection) + if ('ResizeObserver' in window) { + const resizeObserver = new ResizeObserver(trigger); + resizeObserver.observe(current); + + // Cleanup function to disconnect observer + return () => { + resizeObserver.disconnect(); + }; + } + + // Fallback for browsers without ResizeObserver support + // Add a small delay to ensure the element is fully rendered + setTimeout(trigger, 0); + } + }, [callback, ref]); + + return isOverflow; +}; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index ec2585e8c..f7e5e0865 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,9 @@ +@import 'material-symbols/rounded.css'; + +.material-symbols-rounded { + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; +} + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 1ee0c99ed..d24c58b44 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -70,7 +70,7 @@ function HomePageContent() { }, [clearToolSelection]); const handleReaderToggle = useCallback(() => { - setReaderMode(!readerMode); + setReaderMode(true); }, [readerMode]); const handleViewChange = useCallback((view: string) => { @@ -104,7 +104,7 @@ function HomePageContent() { {/* Left: Tool Picker or Selected Tool Panel */}
{/* Top Controls */} Date: Fri, 25 Jul 2025 14:40:58 +0100 Subject: [PATCH 04/29] Deploy on v2 commit (#4033) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/deploy-on-v2-commit.yml | 183 ++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 .github/workflows/deploy-on-v2-commit.yml diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml new file mode 100644 index 000000000..eb12d5cd9 --- /dev/null +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -0,0 +1,183 @@ +name: Auto V2 Deploy on Push + +on: + push: + branches: + - V2 + - deploy-on-v2-commit + +permissions: + contents: read + +jobs: + deploy-v2-on-push: + runs-on: ubuntu-latest + concurrency: + group: deploy-v2-push-V2 + cancel-in-progress: true + + steps: + - name: Harden Runner + uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Get commit hashes for frontend and backend + id: commit-hashes + run: | + # Get last commit that touched the frontend folder, docker/frontend, or docker/compose + FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "") + if [ -z "$FRONTEND_HASH" ]; then + FRONTEND_HASH="no-frontend-changes" + fi + + # Get last commit that touched backend code, docker/backend, or docker/compose + BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "") + if [ -z "$BACKEND_HASH" ]; then + BACKEND_HASH="no-backend-changes" + fi + + echo "Frontend hash: $FRONTEND_HASH" + echo "Backend hash: $BACKEND_HASH" + + echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT + echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT + + # Short hashes for tags + if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then + echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT + else + echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT + fi + + if [ "$BACKEND_HASH" = "no-backend-changes" ]; then + echo "backend_short=no-backend" >> $GITHUB_OUTPUT + else + echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT + fi + + - name: Check if frontend image exists + id: check-frontend + run: | + if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Frontend image already exists, skipping build" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Frontend image needs to be built" + fi + + - name: Check if backend image exists + id: check-backend + run: | + if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Backend image already exists, skipping build" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Backend image needs to be built" + fi + + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_API }} + + - name: Build and push frontend image + if: steps.check-frontend.outputs.exists == 'false' + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/frontend/Dockerfile + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + build-args: VERSION_TAG=v2-alpha + platforms: linux/amd64 + + - name: Build and push backend image + if: steps.check-backend.outputs.exists == 'false' + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/backend/Dockerfile + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + build-args: VERSION_TAG=v2-alpha + platforms: linux/amd64 + + + - name: Set up SSH + run: | + mkdir -p ~/.ssh/ + echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key + chmod 600 ../private.key + + + - name: Deploy to VPS on port 3000 + run: | + export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml + + cat > $UNIQUE_NAME << EOF + version: '3.3' + services: + backend: + container_name: stirling-v2-backend + image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + ports: + - "13000:8080" + volumes: + - /stirling/V2/data:/usr/share/tessdata:rw + - /stirling/V2/config:/configs:rw + - /stirling/V2/logs:/logs:rw + environment: + DISABLE_ADDITIONAL_FEATURES: "true" + SECURITY_ENABLELOGIN: "false" + SYSTEM_DEFAULTLOCALE: en-GB + UI_APPNAME: "Stirling-PDF V2" + UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split" + UI_APPNAMENAVBAR: "V2 Deployment" + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + restart: on-failure:5 + + frontend: + container_name: stirling-v2-frontend + image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + ports: + - "3000:80" + environment: + VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000" + depends_on: + - backend + restart: on-failure:5 + EOF + + # Copy to remote with unique name + scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME + + # SSH and rename/move atomically to avoid interference + ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH + mkdir -p /stirling/V2/{data,config,logs} + mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml + cd /stirling/V2 + docker-compose down || true + docker-compose pull + docker-compose up -d + docker system prune -af --volumes + docker image prune -af --filter "until=336h" --filter "label!=keep=true" + ENDSSH + + - name: Cleanup temporary files + if: always() + run: | + rm -f ../private.key + From 1ce813bcd8d6819f971d0dedf964c40be1f413fc Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:11:53 +0100 Subject: [PATCH 05/29] Endpoint UI data for V2 (#4044) # Description of Changes This pull request introduces several enhancements and new features, primarily focused on improving API documentation support, expanding backend functionality, and adding new frontend tools. Key changes include the integration of Swagger API documentation, the addition of a new `UIDataController` for backend data handling, and updates to the frontend to include a Swagger UI tool. ### Backend Enhancements: * **Swagger API Documentation Integration**: - Added support for dynamically configuring Swagger servers using the `SWAGGER_SERVER_URL` environment variable in `OpenApiConfig` (`[app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.javaR54-L63](diffhunk://#diff-6080fb3dc14efc430c9de1bf9fa4996a23deebc14230dde7788949b2c49cca68R54-L63)`). - Imported necessary Swagger dependencies (`[app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.javaR13](diffhunk://#diff-6080fb3dc14efc430c9de1bf9fa4996a23deebc14230dde7788949b2c49cca68R13)`). - Updated `nginx.conf` to proxy Swagger-related requests to the backend (`[docker/frontend/nginx.confR55-R92](diffhunk://#diff-6d35fafb4405bd052c6d5e48bd946bcef7c77552a74e1b902de45e85eee09aceR55-R92)`). * **New `UIDataController`**: - Introduced a new controller (`UIDataController`) to serve React UI data, including endpoints for home data, licenses, pipeline configurations, signature data, and OCR data (`[app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.javaR1-R301](diffhunk://#diff-3e7063d4e921c7b9e6eedfcad0e535ba3eff68476dcff5e6f28b00c388cff646R1-R301)`). * **Endpoint Handling**: - Modified `ConfigController` to include explicit parameter naming for better clarity in API requests (`[app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.javaL113-R120](diffhunk://#diff-43d19d45ae547fd79090596c06d58cb0eb7f722ed43eb4da59f9dec39f6def6eL113-R120)`). ### Frontend Enhancements: * **Swagger UI Tool**: - Added a new tool definition (`swagger`) in `useToolManagement.tsx`, with an icon and lazy-loaded component (`[frontend/src/hooks/useToolManagement.tsxR30-R38](diffhunk://#diff-57f8a6b3e75ecaec10ad445b01afe8fccc376af6f8ad4d693c68cf98e8863273R30-R38)`). - Implemented the `SwaggerUI` component to open the Swagger documentation in a new tab (`[frontend/src/tools/SwaggerUI.tsxR1-R18](diffhunk://#diff-ca9bdf83c5d611a5edff10255103d7939895635b33a258dd77db6571da6c4600R1-R18)`). * **Localization Updates**: - Updated English (US and GB) translation files to include Swagger-related strings (`[[1]](diffhunk://#diff-14c707e28788a3a84ed5293ff6689be73d4bca00e155beaf090f9b37c978babbR578-R581)`, `[[2]](diffhunk://#diff-14c707e28788a3a84ed5293ff6689be73d4bca00e155beaf090f9b37c978babbR1528-R1533)`, `[[3]](diffhunk://#diff-e4d543afa388d9eb8a423e45dfebb91641e3558d00848d70b285ebb91c40b249R578-R581)`, `[[4]](diffhunk://#diff-e4d543afa388d9eb8a423e45dfebb91641e3558d00848d70b285ebb91c40b249R1528-R1533)`). ### Workflow Updates: * **Environment Variable Additions**: - Added `SWAGGER_SERVER_URL` to the `PR-Auto-Deploy-V2.yml` and `deploy-on-v2-commit.yml` workflows for configuring Swagger server URLs (`[[1]](diffhunk://#diff-931fcb06ba030420d7044dde06465ad55b4e769a9bd374dcd6a0c76f79a5e30eR320)`, `[[2]](diffhunk://#diff-f8b6ec3c0af9cd2d8dffef6f3def2be6357fe596a606850ca7f5d799e1349069R150)`). --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/PR-Auto-Deploy-V2.yml | 1 + .github/workflows/deploy-on-v2-commit.yml | 1 + .gitignore | 2 +- .../SPDF/config/CleanUrlInterceptor.java | 5 + .../software/SPDF/config/OpenApiConfig.java | 16 +- .../SPDF/controller/api/UIDataController.java | 301 +++++++++++ .../controller/api/misc/ConfigController.java | 4 +- .../api/ProprietaryUIDataController.java | 484 ++++++++++++++++++ docker/frontend/nginx.conf | 38 ++ .../public/locales/en-GB/translation.json | 10 + .../public/locales/en-US/translation.json | 10 + frontend/src/hooks/useToolManagement.tsx | 10 + frontend/src/tools/SwaggerUI.tsx | 18 + 13 files changed, 894 insertions(+), 6 deletions(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java create mode 100644 frontend/src/tools/SwaggerUI.tsx diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index a8f971d53..c8fabfd59 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -317,6 +317,7 @@ jobs: SYSTEM_MAXFILESIZE: "100" METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "false" + SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:${V2_PORT}" restart: on-failure:5 stirling-pdf-v2-frontend: diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index eb12d5cd9..d5e3a7b16 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -147,6 +147,7 @@ jobs: SYSTEM_MAXFILESIZE: "100" METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "false" + SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:3000" restart: on-failure:5 frontend: diff --git a/.gitignore b/.gitignore index 8a5168e49..de9f6f24a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ clientWebUI/ !cucumber/exampleFiles/ !cucumber/exampleFiles/example_html.zip exampleYmlFiles/stirling/ -stirling/ +/stirling/ /testing/file_snapshots SwaggerDoc.json diff --git a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java index 088c0c0bf..a696df56a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java @@ -39,6 +39,11 @@ public class CleanUrlInterceptor implements HandlerInterceptor { String queryString = request.getQueryString(); if (queryString != null && !queryString.isEmpty()) { String requestURI = request.getRequestURI(); + + if (requestURI.contains("/api/")) { + return true; + } + Map allowedParameters = new HashMap<>(); // Keep only the allowed parameters diff --git a/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java index 78d2a3d2b..1a5635baa 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; import lombok.RequiredArgsConstructor; @@ -50,17 +51,26 @@ public class OpenApiConfig { .url("https://www.stirlingpdf.com") .email("contact@stirlingpdf.com")) .description(DEFAULT_DESCRIPTION); + + OpenAPI openAPI = new OpenAPI().info(info); + + // Add server configuration from environment variable + String swaggerServerUrl = System.getenv("SWAGGER_SERVER_URL"); + if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) { + Server server = new Server().url(swaggerServerUrl).description("API Server"); + openAPI.addServersItem(server); + } + if (!applicationProperties.getSecurity().getEnableLogin()) { - return new OpenAPI().components(new Components()).info(info); + return openAPI.components(new Components()); } else { SecurityScheme apiKeyScheme = new SecurityScheme() .type(SecurityScheme.Type.APIKEY) .in(SecurityScheme.In.HEADER) .name("X-API-KEY"); - return new OpenAPI() + return openAPI .components(new Components().addSecuritySchemes("apiKey", apiKeyScheme)) - .info(info) .addSecurityItem(new SecurityRequirement().addList("apiKey")); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java new file mode 100644 index 000000000..3161560ae --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -0,0 +1,301 @@ +package stirling.software.SPDF.controller.api; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.Dependency; +import stirling.software.SPDF.model.SignatureFile; +import stirling.software.SPDF.service.SignatureService; +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.GeneralUtils; + +@Slf4j +@RestController +@RequestMapping("/api/v1/ui-data") +@Tag(name = "UI Data", description = "APIs for React UI data") +public class UIDataController { + + private final ApplicationProperties applicationProperties; + private final SignatureService signatureService; + private final UserServiceInterface userService; + private final ResourceLoader resourceLoader; + private final RuntimePathConfig runtimePathConfig; + + public UIDataController( + ApplicationProperties applicationProperties, + SignatureService signatureService, + @Autowired(required = false) UserServiceInterface userService, + ResourceLoader resourceLoader, + RuntimePathConfig runtimePathConfig) { + this.applicationProperties = applicationProperties; + this.signatureService = signatureService; + this.userService = userService; + this.resourceLoader = resourceLoader; + this.runtimePathConfig = runtimePathConfig; + } + + @GetMapping("/home") + @Operation(summary = "Get home page data") + public ResponseEntity getHomeData() { + String showSurvey = System.getenv("SHOW_SURVEY"); + boolean showSurveyValue = showSurvey == null || "true".equalsIgnoreCase(showSurvey); + + HomeData data = new HomeData(); + data.setShowSurveyFromDocker(showSurveyValue); + + return ResponseEntity.ok(data); + } + + @GetMapping("/licenses") + @Operation(summary = "Get third-party licenses data") + public ResponseEntity getLicensesData() { + LicensesData data = new LicensesData(); + Resource resource = new ClassPathResource("static/3rdPartyLicenses.json"); + + try { + InputStream is = resource.getInputStream(); + String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); + ObjectMapper mapper = new ObjectMapper(); + Map> licenseData = + mapper.readValue(json, new TypeReference<>() {}); + data.setDependencies(licenseData.get("dependencies")); + } catch (IOException e) { + log.error("Failed to load licenses data", e); + data.setDependencies(Collections.emptyList()); + } + + return ResponseEntity.ok(data); + } + + @GetMapping("/pipeline") + @Operation(summary = "Get pipeline configuration data") + public ResponseEntity getPipelineData() { + PipelineData data = new PipelineData(); + List pipelineConfigs = new ArrayList<>(); + List> pipelineConfigsWithNames = new ArrayList<>(); + + if (new java.io.File(runtimePathConfig.getPipelineDefaultWebUiConfigs()).exists()) { + try (Stream paths = + Files.walk(Paths.get(runtimePathConfig.getPipelineDefaultWebUiConfigs()))) { + List jsonFiles = + paths.filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".json")) + .toList(); + + for (Path jsonFile : jsonFiles) { + String content = Files.readString(jsonFile, StandardCharsets.UTF_8); + pipelineConfigs.add(content); + } + + for (String config : pipelineConfigs) { + Map jsonContent = + new ObjectMapper() + .readValue(config, new TypeReference>() {}); + String name = (String) jsonContent.get("name"); + if (name == null || name.length() < 1) { + String filename = + jsonFiles + .get(pipelineConfigs.indexOf(config)) + .getFileName() + .toString(); + name = filename.substring(0, filename.lastIndexOf('.')); + } + Map configWithName = new HashMap<>(); + configWithName.put("json", config); + configWithName.put("name", name); + pipelineConfigsWithNames.add(configWithName); + } + } catch (IOException e) { + log.error("Failed to load pipeline configs", e); + } + } + + if (pipelineConfigsWithNames.isEmpty()) { + Map configWithName = new HashMap<>(); + configWithName.put("json", ""); + configWithName.put("name", "No preloaded configs found"); + pipelineConfigsWithNames.add(configWithName); + } + + data.setPipelineConfigsWithNames(pipelineConfigsWithNames); + data.setPipelineConfigs(pipelineConfigs); + + return ResponseEntity.ok(data); + } + + @GetMapping("/sign") + @Operation(summary = "Get signature form data") + public ResponseEntity getSignData() { + String username = ""; + if (userService != null) { + username = userService.getCurrentUsername(); + } + + List signatures = signatureService.getAvailableSignatures(username); + List fonts = getFontNames(); + + SignData data = new SignData(); + data.setSignatures(signatures); + data.setFonts(fonts); + + return ResponseEntity.ok(data); + } + + @GetMapping("/ocr-pdf") + @Operation(summary = "Get OCR PDF data") + public ResponseEntity getOcrPdfData() { + List languages = getAvailableTesseractLanguages(); + + OcrData data = new OcrData(); + data.setLanguages(languages); + + return ResponseEntity.ok(data); + } + + private List getAvailableTesseractLanguages() { + String tessdataDir = applicationProperties.getSystem().getTessdataDir(); + java.io.File[] files = new java.io.File(tessdataDir).listFiles(); + if (files == null) { + return Collections.emptyList(); + } + return Arrays.stream(files) + .filter(file -> file.getName().endsWith(".traineddata")) + .map(file -> file.getName().replace(".traineddata", "")) + .filter(lang -> !"osd".equalsIgnoreCase(lang)) + .sorted() + .toList(); + } + + private List getFontNames() { + List fontNames = new ArrayList<>(); + fontNames.addAll(getFontNamesFromLocation("classpath:static/fonts/*.woff2")); + fontNames.addAll( + getFontNamesFromLocation( + "file:" + + InstallationPathConfig.getStaticPath() + + "fonts" + + java.io.File.separator + + "*")); + return fontNames; + } + + private List getFontNamesFromLocation(String locationPattern) { + try { + Resource[] resources = + GeneralUtils.getResourcesFromLocationPattern(locationPattern, resourceLoader); + return Arrays.stream(resources) + .map( + resource -> { + try { + String filename = resource.getFilename(); + if (filename != null) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex != -1) { + String name = filename.substring(0, lastDotIndex); + String extension = filename.substring(lastDotIndex + 1); + return new FontResource(name, extension); + } + } + return null; + } catch (Exception e) { + throw ExceptionUtils.createRuntimeException( + "error.fontLoadingFailed", + "Error processing font file", + e); + } + }) + .filter(Objects::nonNull) + .toList(); + } catch (Exception e) { + throw ExceptionUtils.createRuntimeException( + "error.fontDirectoryReadFailed", "Failed to read font directory", e); + } + } + + // Data classes + @Data + public static class HomeData { + private boolean showSurveyFromDocker; + } + + @Data + public static class LicensesData { + private List dependencies; + } + + @Data + public static class PipelineData { + private List> pipelineConfigsWithNames; + private List pipelineConfigs; + } + + @Data + public static class SignData { + private List signatures; + private List fonts; + } + + @Data + public static class OcrData { + private List languages; + } + + @Data + public static class FontResource { + private String name; + private String extension; + private String type; + + public FontResource(String name, String extension) { + this.name = name; + this.extension = extension; + this.type = getFormatFromExtension(extension); + } + + private static String getFormatFromExtension(String extension) { + switch (extension) { + case "ttf": + return "truetype"; + case "woff": + return "woff"; + case "woff2": + return "woff2"; + case "eot": + return "embedded-opentype"; + case "svg": + return "svg"; + default: + return ""; + } + } + } +} 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 6b69e4b2e..fb1b7b2ca 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 @@ -110,14 +110,14 @@ public class ConfigController { } @GetMapping("/endpoint-enabled") - public ResponseEntity isEndpointEnabled(@RequestParam String endpoint) { + public ResponseEntity isEndpointEnabled(@RequestParam(name = "endpoint") String endpoint) { boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint); return ResponseEntity.ok(enabled); } @GetMapping("/endpoints-enabled") public ResponseEntity> areEndpointsEnabled( - @RequestParam String endpoints) { + @RequestParam(name = "endpoints") String endpoints) { Map result = new HashMap<>(); String[] endpointArray = endpoints.split(","); for (String endpoint : endpointArray) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java new file mode 100644 index 000000000..f4eb114ef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -0,0 +1,484 @@ +package stirling.software.proprietary.controller.api; + +import static stirling.software.common.util.ProviderUtils.validateProvider; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Security; +import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; +import stirling.software.common.model.ApplicationProperties.Security.OAUTH2.Client; +import stirling.software.common.model.ApplicationProperties.Security.SAML2; +import stirling.software.common.model.FileInfo; +import stirling.software.common.model.enumeration.Role; +import stirling.software.common.model.oauth2.GitHubProvider; +import stirling.software.common.model.oauth2.GoogleProvider; +import stirling.software.common.model.oauth2.KeycloakProvider; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.dto.TeamWithUserCountDTO; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; +import stirling.software.proprietary.security.database.repository.SessionRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.SessionEntity; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; +import stirling.software.proprietary.security.service.DatabaseService; +import stirling.software.proprietary.security.service.TeamService; +import stirling.software.proprietary.security.session.SessionPersistentRegistry; + +@Slf4j +@RestController +@RequestMapping("/api/v1/proprietary/ui-data") +@Tag(name = "Proprietary UI Data", description = "APIs for React UI data (Proprietary features)") +@EnterpriseEndpoint +public class ProprietaryUIDataController { + + private final ApplicationProperties applicationProperties; + private final AuditConfigurationProperties auditConfig; + private final SessionPersistentRegistry sessionPersistentRegistry; + private final UserRepository userRepository; + private final TeamRepository teamRepository; + private final SessionRepository sessionRepository; + private final DatabaseService databaseService; + private final boolean runningEE; + private final ObjectMapper objectMapper; + + public ProprietaryUIDataController( + ApplicationProperties applicationProperties, + AuditConfigurationProperties auditConfig, + SessionPersistentRegistry sessionPersistentRegistry, + UserRepository userRepository, + TeamRepository teamRepository, + SessionRepository sessionRepository, + DatabaseService databaseService, + ObjectMapper objectMapper, + @Qualifier("runningEE") boolean runningEE) { + this.applicationProperties = applicationProperties; + this.auditConfig = auditConfig; + this.sessionPersistentRegistry = sessionPersistentRegistry; + this.userRepository = userRepository; + this.teamRepository = teamRepository; + this.sessionRepository = sessionRepository; + this.databaseService = databaseService; + this.objectMapper = objectMapper; + this.runningEE = runningEE; + } + + @GetMapping("/audit-dashboard") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get audit dashboard data") + public ResponseEntity getAuditDashboardData() { + AuditDashboardData data = new AuditDashboardData(); + data.setAuditEnabled(auditConfig.isEnabled()); + data.setAuditLevel(auditConfig.getAuditLevel()); + data.setAuditLevelInt(auditConfig.getLevel()); + data.setRetentionDays(auditConfig.getRetentionDays()); + data.setAuditLevels(AuditLevel.values()); + data.setAuditEventTypes(AuditEventType.values()); + + return ResponseEntity.ok(data); + } + + @GetMapping("/login") + @Operation(summary = "Get login page data") + public ResponseEntity getLoginData() { + LoginData data = new LoginData(); + Map providerList = new HashMap<>(); + Security securityProps = applicationProperties.getSecurity(); + OAUTH2 oauth = securityProps.getOauth2(); + + if (oauth != null && oauth.getEnabled()) { + if (oauth.isSettingsValid()) { + String firstChar = String.valueOf(oauth.getProvider().charAt(0)); + String clientName = + oauth.getProvider().replaceFirst(firstChar, firstChar.toUpperCase()); + providerList.put("/oauth2/authorization/" + oauth.getProvider(), clientName); + } + + Client client = oauth.getClient(); + if (client != null) { + GoogleProvider google = client.getGoogle(); + if (validateProvider(google)) { + providerList.put( + "/oauth2/authorization/" + google.getName(), google.getClientName()); + } + + GitHubProvider github = client.getGithub(); + if (validateProvider(github)) { + providerList.put( + "/oauth2/authorization/" + github.getName(), github.getClientName()); + } + + KeycloakProvider keycloak = client.getKeycloak(); + if (validateProvider(keycloak)) { + providerList.put( + "/oauth2/authorization/" + keycloak.getName(), + keycloak.getClientName()); + } + } + } + + SAML2 saml2 = securityProps.getSaml2(); + if (securityProps.isSaml2Active() + && applicationProperties.getSystem().getEnableAlphaFunctionality() + && applicationProperties.getPremium().isEnabled()) { + String samlIdp = saml2.getProvider(); + String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); + + if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) { + providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); + } + } + + // Remove null entries + providerList + .entrySet() + .removeIf(entry -> entry.getKey() == null || entry.getValue() == null); + + data.setProviderList(providerList); + data.setLoginMethod(securityProps.getLoginMethod()); + data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin()); + + return ResponseEntity.ok(data); + } + + @GetMapping("/admin-settings") + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Operation(summary = "Get admin settings data") + public ResponseEntity getAdminSettingsData(Authentication authentication) { + List allUsers = userRepository.findAllWithTeam(); + Iterator iterator = allUsers.iterator(); + Map roleDetails = Role.getAllRoleDetails(); + + Map userSessions = new HashMap<>(); + Map userLastRequest = new HashMap<>(); + int activeUsers = 0; + int disabledUsers = 0; + + while (iterator.hasNext()) { + User user = iterator.next(); + if (user != null) { + boolean shouldRemove = false; + + // Check if user is an INTERNAL_API_USER + for (Authority authority : user.getAuthorities()) { + if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) { + shouldRemove = true; + roleDetails.remove(Role.INTERNAL_API_USER.getRoleId()); + break; + } + } + + // Check if user is part of the Internal team + if (user.getTeam() != null + && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { + shouldRemove = true; + } + + if (shouldRemove) { + iterator.remove(); + continue; + } + + // Session status and last request time + int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); + boolean hasActiveSession = false; + Date lastRequest = null; + Optional latestSession = + sessionPersistentRegistry.findLatestSession(user.getUsername()); + + if (latestSession.isPresent()) { + SessionEntity sessionEntity = latestSession.get(); + Date lastAccessedTime = sessionEntity.getLastRequest(); + Instant now = Instant.now(); + Instant expirationTime = + lastAccessedTime + .toInstant() + .plus(maxInactiveInterval, ChronoUnit.SECONDS); + + if (now.isAfter(expirationTime)) { + sessionPersistentRegistry.expireSession(sessionEntity.getSessionId()); + } else { + hasActiveSession = !sessionEntity.isExpired(); + } + lastRequest = sessionEntity.getLastRequest(); + } else { + lastRequest = new Date(0); + } + + userSessions.put(user.getUsername(), hasActiveSession); + userLastRequest.put(user.getUsername(), lastRequest); + + if (hasActiveSession) activeUsers++; + if (!user.isEnabled()) disabledUsers++; + } + } + + // Sort users by active status and last request date + List sortedUsers = + allUsers.stream() + .sorted( + (u1, u2) -> { + boolean u1Active = userSessions.get(u1.getUsername()); + boolean u2Active = userSessions.get(u2.getUsername()); + if (u1Active && !u2Active) return -1; + if (!u1Active && u2Active) return 1; + + Date u1LastRequest = + userLastRequest.getOrDefault( + u1.getUsername(), new Date(0)); + Date u2LastRequest = + userLastRequest.getOrDefault( + u2.getUsername(), new Date(0)); + return u2LastRequest.compareTo(u1LastRequest); + }) + .toList(); + + List allTeams = + teamRepository.findAll().stream() + .filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) + .toList(); + + AdminSettingsData data = new AdminSettingsData(); + data.setUsers(sortedUsers); + data.setCurrentUsername(authentication.getName()); + data.setRoleDetails(roleDetails); + data.setUserSessions(userSessions); + data.setUserLastRequest(userLastRequest); + data.setTotalUsers(allUsers.size()); + data.setActiveUsers(activeUsers); + data.setDisabledUsers(disabledUsers); + data.setTeams(allTeams); + data.setMaxPaidUsers(applicationProperties.getPremium().getMaxUsers()); + + return ResponseEntity.ok(data); + } + + @GetMapping("/account") + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @Operation(summary = "Get account page data") + public ResponseEntity getAccountData(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(401).build(); + } + + Object principal = authentication.getPrincipal(); + String username = null; + boolean isOAuth2Login = false; + boolean isSaml2Login = false; + + if (principal instanceof UserDetails detailsUser) { + username = detailsUser.getUsername(); + } else if (principal instanceof OAuth2User oAuth2User) { + username = oAuth2User.getName(); + isOAuth2Login = true; + } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { + username = saml2User.name(); + isSaml2Login = true; + } + + if (username == null) { + return ResponseEntity.status(401).build(); + } + + Optional user = userRepository.findByUsernameIgnoreCaseWithSettings(username); + if (user.isEmpty()) { + return ResponseEntity.status(404).build(); + } + + String settingsJson; + try { + settingsJson = objectMapper.writeValueAsString(user.get().getSettings()); + } catch (JsonProcessingException e) { + log.error("Error converting settings map", e); + return ResponseEntity.status(500).build(); + } + + AccountData data = new AccountData(); + data.setUsername(username); + data.setRole(user.get().getRolesAsString()); + data.setSettings(settingsJson); + data.setChangeCredsFlag(user.get().isFirstLogin()); + data.setOAuth2Login(isOAuth2Login); + data.setSaml2Login(isSaml2Login); + + return ResponseEntity.ok(data); + } + + @GetMapping("/teams") + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Operation(summary = "Get teams list data") + public ResponseEntity getTeamsData() { + List allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount(); + List teamsWithCounts = + allTeamsWithCounts.stream() + .filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) + .toList(); + + List teamActivities = sessionRepository.findLatestActivityByTeam(); + Map teamLastRequest = new HashMap<>(); + for (Object[] result : teamActivities) { + Long teamId = (Long) result[0]; + Date lastActivity = (Date) result[1]; + teamLastRequest.put(teamId, lastActivity); + } + + TeamsData data = new TeamsData(); + data.setTeamsWithCounts(teamsWithCounts); + data.setTeamLastRequest(teamLastRequest); + + return ResponseEntity.ok(data); + } + + @GetMapping("/teams/{id}") + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Operation(summary = "Get team details data") + public ResponseEntity getTeamDetailsData(@PathVariable("id") Long id) { + Team team = + teamRepository + .findById(id) + .orElseThrow(() -> new RuntimeException("Team not found")); + + if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { + return ResponseEntity.status(403).build(); + } + + List teamUsers = userRepository.findAllByTeamId(id); + List allUsers = userRepository.findAllWithTeam(); + List availableUsers = + allUsers.stream() + .filter( + user -> + (user.getTeam() == null + || !user.getTeam().getId().equals(id)) + && (user.getTeam() == null + || !user.getTeam() + .getName() + .equals( + TeamService + .INTERNAL_TEAM_NAME))) + .toList(); + + List userSessions = sessionRepository.findLatestSessionByTeamId(id); + Map userLastRequest = new HashMap<>(); + for (Object[] result : userSessions) { + String username = (String) result[0]; + Date lastRequest = (Date) result[1]; + userLastRequest.put(username, lastRequest); + } + + TeamDetailsData data = new TeamDetailsData(); + data.setTeam(team); + data.setTeamUsers(teamUsers); + data.setAvailableUsers(availableUsers); + data.setUserLastRequest(userLastRequest); + + return ResponseEntity.ok(data); + } + + @GetMapping("/database") + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Operation(summary = "Get database management data") + public ResponseEntity getDatabaseData() { + List backupList = databaseService.getBackupList(); + String dbVersion = databaseService.getH2Version(); + boolean isVersionUnknown = "Unknown".equalsIgnoreCase(dbVersion); + + DatabaseData data = new DatabaseData(); + data.setBackupFiles(backupList); + data.setDatabaseVersion(dbVersion); + data.setVersionUnknown(isVersionUnknown); + + return ResponseEntity.ok(data); + } + + // Data classes + @Data + public static class AuditDashboardData { + private boolean auditEnabled; + private AuditLevel auditLevel; + private int auditLevelInt; + private int retentionDays; + private AuditLevel[] auditLevels; + private AuditEventType[] auditEventTypes; + } + + @Data + public static class LoginData { + private Map providerList; + private String loginMethod; + private boolean altLogin; + } + + @Data + public static class AdminSettingsData { + private List users; + private String currentUsername; + private Map roleDetails; + private Map userSessions; + private Map userLastRequest; + private int totalUsers; + private int activeUsers; + private int disabledUsers; + private List teams; + private int maxPaidUsers; + } + + @Data + public static class AccountData { + private String username; + private String role; + private String settings; + private boolean changeCredsFlag; + private boolean oAuth2Login; + private boolean saml2Login; + } + + @Data + public static class TeamsData { + private List teamsWithCounts; + private Map teamLastRequest; + } + + @Data + public static class TeamDetailsData { + private Team team; + private List teamUsers; + private List availableUsers; + private Map userLastRequest; + } + + @Data + public static class DatabaseData { + private List backupFiles; + private String databaseVersion; + private boolean versionUnknown; + } +} diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index 456d70140..af4ca85f2 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -52,6 +52,44 @@ http { proxy_request_buffering off; } + # Proxy Swagger UI to backend (including versioned paths) + location ~ ^/swagger-ui(.*)$ { + proxy_pass ${VITE_API_BASE_URL}/swagger-ui$1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_set_header Connection ''; + proxy_http_version 1.1; + proxy_buffering off; + proxy_cache off; + } + + # Proxy API docs to backend (with query parameters and sub-paths) + location ~ ^/v3/api-docs(.*)$ { + proxy_pass ${VITE_API_BASE_URL}/v3/api-docs$1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } + + # Proxy v1 API docs to backend (with query parameters and sub-paths) + location ~ ^/v1/api-docs(.*)$ { + proxy_pass ${VITE_API_BASE_URL}/v1/api-docs$1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } + # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 081f746ee..37c0e5355 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -575,6 +575,10 @@ "title": "Validate PDF Signature", "desc": "Verify digital signatures and certificates in PDF documents" }, + "swagger": { + "title": "API Documentation", + "desc": "View API documentation and test endpoints" + }, "replaceColorPdf": { "title": "Advanced Colour options", "desc": "Replace colour for text and background in PDF and invert full colour of pdf to reduce file size" @@ -1521,6 +1525,12 @@ }, "note": "Release notes are only available in English" }, + "swagger": { + "title": "API Documentation", + "header": "API Documentation", + "desc": "View and test the Stirling PDF API endpoints", + "tags": "api,documentation,swagger,endpoints,development" + }, "cookieBanner": { "popUp": { "title": "How we use Cookies", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index e73175694..1b258a824 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -575,6 +575,10 @@ "title": "Validate PDF Signature", "desc": "Verify digital signatures and certificates in PDF documents" }, + "swagger": { + "title": "API Documentation", + "desc": "View API documentation and test endpoints" + }, "replaceColorPdf": { "title": "Replace and Invert Color", "desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size" @@ -1521,6 +1525,12 @@ }, "note": "Release notes are only available in English" }, + "swagger": { + "title": "API Documentation", + "header": "API Documentation", + "desc": "View and test the Stirling PDF API endpoints", + "tags": "api,documentation,swagger,endpoints,development" + }, "cookieBanner": { "popUp": { "title": "How we use Cookies", diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 7ada59024..bdf2744ac 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -2,6 +2,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import ContentCutIcon from "@mui/icons-material/ContentCut"; import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; +import ApiIcon from "@mui/icons-material/Api"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool"; @@ -26,6 +27,15 @@ const toolDefinitions: Record = { description: "Reduce PDF file size", endpoints: ["compress-pdf"] }, + swagger: { + id: "swagger", + icon: , + component: React.lazy(() => import("../tools/SwaggerUI")), + maxFiles: 0, + category: "utility", + description: "Open API documentation", + endpoints: ["swagger-ui"] + }, }; diff --git a/frontend/src/tools/SwaggerUI.tsx b/frontend/src/tools/SwaggerUI.tsx new file mode 100644 index 000000000..0712b6068 --- /dev/null +++ b/frontend/src/tools/SwaggerUI.tsx @@ -0,0 +1,18 @@ +import React, { useEffect } from 'react'; +import { BaseToolProps } from '../types/tool'; + +const SwaggerUI: React.FC = () => { + useEffect(() => { + // Redirect to Swagger UI + window.open('/swagger-ui/5.21.0/index.html', '_blank'); + }, []); + + return ( + + ); +}; + +export default SwaggerUI; \ No newline at end of file From 75bd154a82cc51dbf530b6be85e92634cfdbcec8 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:44:17 +0100 Subject: [PATCH 06/29] V2 async requests (#4043) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: a --- .../controller/api/AnalysisController.java | 18 ++++++++++-------- .../SPDF/controller/api/CropController.java | 4 +++- .../api/EditTableOfContentsController.java | 6 ++++-- .../SPDF/controller/api/MergeController.java | 4 +++- .../api/MultiPageLayoutController.java | 4 +++- .../api/PdfImageRemovalController.java | 4 +++- .../controller/api/PdfOverlayController.java | 4 +++- .../api/RearrangePagesPDFController.java | 6 ++++-- .../controller/api/RotationController.java | 4 +++- .../controller/api/ScalePagesController.java | 4 +++- .../controller/api/SettingsController.java | 4 +++- .../controller/api/SplitPDFController.java | 4 +++- .../api/SplitPdfByChaptersController.java | 4 +++- .../api/SplitPdfBySectionsController.java | 4 +++- .../api/SplitPdfBySizeController.java | 4 +++- .../controller/api/ToSinglePageController.java | 4 +++- .../api/converters/ConvertEmlToPDF.java | 4 +++- .../api/converters/ConvertHtmlToPDF.java | 4 +++- .../converters/ConvertImgPDFController.java | 6 ++++-- .../api/converters/ConvertMarkdownToPdf.java | 4 +++- .../converters/ConvertOfficeController.java | 4 +++- .../api/converters/ConvertPDFToHtml.java | 4 +++- .../api/converters/ConvertPDFToOffice.java | 10 ++++++---- .../api/converters/ConvertPDFToPDFA.java | 4 +++- .../api/converters/ConvertWebsiteToPDF.java | 4 +++- .../api/converters/ExtractCSVController.java | 4 +++- .../api/filters/FilterController.java | 14 ++++++++------ .../api/misc/AttachmentController.java | 4 +++- .../api/misc/AutoRenameController.java | 4 +++- .../api/misc/AutoSplitPdfController.java | 4 +++- .../api/misc/BlankPageController.java | 4 +++- .../api/misc/CompressController.java | 4 +++- .../api/misc/DecompressPdfController.java | 4 +++- .../api/misc/ExtractImageScansController.java | 4 +++- .../api/misc/ExtractImagesController.java | 4 +++- .../controller/api/misc/FlattenController.java | 4 +++- .../api/misc/MetadataController.java | 4 +++- .../controller/api/misc/OCRController.java | 4 +++- .../api/misc/OverlayImageController.java | 4 +++- .../api/misc/PageNumbersController.java | 4 +++- .../api/misc/PrintFileController.java | 4 +++- .../controller/api/misc/RepairController.java | 4 +++- .../misc/ReplaceAndInvertColorController.java | 4 +++- .../api/misc/ScannerEffectController.java | 4 +++- .../controller/api/misc/ShowJavascript.java | 4 +++- .../controller/api/misc/StampController.java | 4 +++- .../api/misc/UnlockPDFFormsController.java | 4 +++- .../api/pipeline/PipelineController.java | 4 +++- .../api/security/CertSignController.java | 4 +++- .../controller/api/security/GetInfoOnPDF.java | 4 +++- .../api/security/PasswordController.java | 6 ++++-- .../api/security/RedactController.java | 6 ++++-- .../api/security/RemoveCertSignController.java | 4 +++- .../api/security/SanitizeController.java | 4 +++- .../security/ValidateSignatureController.java | 4 +++- .../api/security/WatermarkController.java | 4 +++- .../api/converters/ConvertPDFToMarkdown.java | 4 +++- .../controller/api/EmailController.java | 4 +++- testing/cucumber/features/examples.feature | 2 +- 59 files changed, 195 insertions(+), 79 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java index b6419890a..27fff75d2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import java.util.*; @@ -29,7 +31,7 @@ public class AnalysisController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/page-count", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/page-count", consumes = "multipart/form-data") @Operation( summary = "Get PDF page count", description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO") @@ -39,7 +41,7 @@ public class AnalysisController { } } - @PostMapping(value = "/basic-info", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/basic-info", consumes = "multipart/form-data") @Operation( summary = "Get basic PDF information", description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO") @@ -53,7 +55,7 @@ public class AnalysisController { } } - @PostMapping(value = "/document-properties", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/document-properties", consumes = "multipart/form-data") @Operation( summary = "Get PDF document properties", description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO") @@ -76,7 +78,7 @@ public class AnalysisController { } } - @PostMapping(value = "/page-dimensions", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/page-dimensions", consumes = "multipart/form-data") @Operation( summary = "Get page dimensions for all pages", description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO") @@ -96,7 +98,7 @@ public class AnalysisController { } } - @PostMapping(value = "/form-fields", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/form-fields", consumes = "multipart/form-data") @Operation( summary = "Get form field information", description = @@ -119,7 +121,7 @@ public class AnalysisController { } } - @PostMapping(value = "/annotation-info", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/annotation-info", consumes = "multipart/form-data") @Operation( summary = "Get annotation information", description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO") @@ -143,7 +145,7 @@ public class AnalysisController { } } - @PostMapping(value = "/font-info", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/font-info", consumes = "multipart/form-data") @Operation( summary = "Get font information", description = @@ -165,7 +167,7 @@ public class AnalysisController { } } - @PostMapping(value = "/security-info", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/security-info", consumes = "multipart/form-data") @Operation( summary = "Get security information", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java index 3a2d16757..f47bd0d0c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -33,7 +35,7 @@ public class CropController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/crop", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/crop", consumes = "multipart/form-data") @Operation( summary = "Crops a PDF document", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java index 6a30e6bb3..2f823695e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.util.ArrayList; import java.util.HashMap; @@ -44,7 +46,7 @@ public class EditTableOfContentsController { private final CustomPDFDocumentFactory pdfDocumentFactory; private final ObjectMapper objectMapper; - @PostMapping(value = "/extract-bookmarks", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/extract-bookmarks", consumes = "multipart/form-data") @Operation( summary = "Extract PDF Bookmarks", description = "Extracts bookmarks/table of contents from a PDF document as JSON.") @@ -152,7 +154,7 @@ public class EditTableOfContentsController { return bookmark; } - @PostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data") @Operation( summary = "Edit Table of Contents", description = "Add or edit bookmarks/table of contents in a PDF document.") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index 4e05392c8..538b82a90 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; @@ -154,7 +156,7 @@ public class MergeController { } } - @PostMapping(consumes = "multipart/form-data", value = "/merge-pdfs") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/merge-pdfs") @Operation( summary = "Merge multiple PDF files into one", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java index c57e3a6c0..d07b5314e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.*; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -36,7 +38,7 @@ public class MultiPageLayoutController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/multi-page-layout", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/multi-page-layout", consumes = "multipart/form-data") @Operation( summary = "Merge multiple pages of a PDF document into a single page", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfImageRemovalController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfImageRemovalController.java index d6602351e..5c262ecc6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfImageRemovalController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfImageRemovalController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -46,7 +48,7 @@ public class PdfImageRemovalController { * content type and filename. * @throws IOException If an error occurs while processing the PDF file. */ - @PostMapping(consumes = "multipart/form-data", value = "/remove-image-pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-image-pdf") @Operation( summary = "Remove images from file to reduce the file size.", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java index e6fc2c561..4f90ddac0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; @@ -39,7 +41,7 @@ public class PdfOverlayController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data") @Operation( summary = "Overlay PDF files in various modes", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java index 717c85016..6254183b0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -38,7 +40,7 @@ public class RearrangePagesPDFController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/remove-pages") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-pages") @Operation( summary = "Remove pages from a PDF file", description = @@ -237,7 +239,7 @@ public class RearrangePagesPDFController { } } - @PostMapping(consumes = "multipart/form-data", value = "/rearrange-pages") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/rearrange-pages") @Operation( summary = "Rearrange pages in a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/RotationController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/RotationController.java index 58b502cfa..e5a8ae90c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/RotationController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/RotationController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import org.apache.pdfbox.pdmodel.PDDocument; @@ -31,7 +33,7 @@ public class RotationController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/rotate-pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/rotate-pdf") @Operation( summary = "Rotate a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java index 56f6f77fa..abc4c4e46 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.HashMap; @@ -38,7 +40,7 @@ public class ScalePagesController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/scale-pages", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/scale-pages", consumes = "multipart/form-data") @Operation( summary = "Change the size of a PDF page/document", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java index 0e9cd96dc..efa77b54a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import java.util.Map; @@ -31,7 +33,7 @@ public class SettingsController { private final ApplicationProperties applicationProperties; private final EndpointConfiguration endpointConfiguration; - @PostMapping("/update-enable-analytics") + @AutoJobPostMapping("/update-enable-analytics") @Hidden public ResponseEntity updateApiKey(@RequestBody Boolean enabled) throws IOException { if (applicationProperties.getSystem().getEnableAnalytics() != null) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java index f2425ac9a..32691b2d0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.file.Files; @@ -41,7 +43,7 @@ public class SplitPDFController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/split-pages") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/split-pages") @Operation( summary = "Split a PDF file into separate documents", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java index f0f9fb012..a30b208c9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -117,7 +119,7 @@ public class SplitPdfByChaptersController { return bookmarks; } - @PostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data") @Operation( summary = "Split PDFs by Chapters", description = "Splits a PDF into chapters and returns a ZIP file.") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java index c2bbd31b5..a27d7f1b9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.file.Files; @@ -43,7 +45,7 @@ public class SplitPdfBySectionsController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data") @Operation( summary = "Split PDF pages into smaller sections", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java index 0dbbd933c..adfe42b46 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.file.Files; @@ -39,7 +41,7 @@ public class SplitPdfBySizeController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data") @Operation( summary = "Auto split PDF pages into separate documents based on size or count", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/ToSinglePageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/ToSinglePageController.java index 104a0f351..e52f1bfef 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/ToSinglePageController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/ToSinglePageController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.geom.AffineTransform; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -33,7 +35,7 @@ public class ToSinglePageController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page") @Operation( summary = "Convert a multi-page PDF into a single long page PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java index 33d51a2a1..1bc2bf2bb 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -38,7 +40,7 @@ public class ConvertEmlToPDF { private final RuntimePathConfig runtimePathConfig; private final TempFileManager tempFileManager; - @PostMapping(consumes = "multipart/form-data", value = "/eml/pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/eml/pdf") @Operation( summary = "Convert EML to PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java index 9b3b64506..5adb65d8f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; @@ -36,7 +38,7 @@ public class ConvertHtmlToPDF { private final TempFileManager tempFileManager; - @PostMapping(consumes = "multipart/form-data", value = "/html/pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf") @Operation( summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java index 5eff72a4a..39af4a002 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; @@ -51,7 +53,7 @@ public class ConvertImgPDFController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/pdf/img") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/img") @Operation( summary = "Convert PDF to image(s)", description = @@ -211,7 +213,7 @@ public class ConvertImgPDFController { } } - @PostMapping(consumes = "multipart/form-data", value = "/img/pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/img/pdf") @Operation( summary = "Convert images to a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java index 6cb22be7e..b86b2a8d1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.util.List; import java.util.Map; @@ -45,7 +47,7 @@ public class ConvertMarkdownToPdf { private final TempFileManager tempFileManager; - @PostMapping(consumes = "multipart/form-data", value = "/markdown/pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf") @Operation( summary = "Convert a Markdown file to PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java index d81e3843f..57e211fcf 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -84,7 +86,7 @@ public class ConvertOfficeController { return fileExtension.matches(extensionPattern); } - @PostMapping(consumes = "multipart/form-data", value = "/file/pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/file/pdf") @Operation( summary = "Convert a file to a PDF using LibreOffice", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java index 9015dee2e..6b47a498b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; @@ -18,7 +20,7 @@ import stirling.software.common.util.PDFToFile; @RequestMapping("/api/v1/convert") public class ConvertPDFToHtml { - @PostMapping(consumes = "multipart/form-data", value = "/pdf/html") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/html") @Operation( summary = "Convert PDF to HTML", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOffice.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOffice.java index 585185460..3d133f943 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOffice.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOffice.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import org.apache.pdfbox.pdmodel.PDDocument; @@ -34,7 +36,7 @@ public class ConvertPDFToOffice { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/pdf/presentation") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/presentation") @Operation( summary = "Convert PDF to Presentation format", description = @@ -49,7 +51,7 @@ public class ConvertPDFToOffice { return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import"); } - @PostMapping(consumes = "multipart/form-data", value = "/pdf/text") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text") @Operation( summary = "Convert PDF to Text or RTF format", description = @@ -77,7 +79,7 @@ public class ConvertPDFToOffice { } } - @PostMapping(consumes = "multipart/form-data", value = "/pdf/word") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/word") @Operation( summary = "Convert PDF to Word document", description = @@ -91,7 +93,7 @@ public class ConvertPDFToOffice { return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import"); } - @PostMapping(consumes = "multipart/form-data", value = "/pdf/xml") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/xml") @Operation( summary = "Convert PDF to XML", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java index 7c5435aaa..1e77e2b44 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.Color; import java.io.ByteArrayOutputStream; import java.io.File; @@ -78,7 +80,7 @@ import stirling.software.common.util.WebResponseUtils; @Tag(name = "Convert", description = "Convert APIs") public class ConvertPDFToPDFA { - @PostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa") @Operation( summary = "Convert a PDF to a PDF/A", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java index a7e194d4f..1b5467587 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -40,7 +42,7 @@ public class ConvertWebsiteToPDF { private final RuntimePathConfig runtimePathConfig; private final ApplicationProperties applicationProperties; - @PostMapping(consumes = "multipart/form-data", value = "/url/pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/url/pdf") @Operation( summary = "Convert a URL to a PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java index 847904b60..2bec58d38 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.StringWriter; @@ -46,7 +48,7 @@ public class ExtractCSVController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/pdf/csv", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/pdf/csv", consumes = "multipart/form-data") @Operation( summary = "Extracts a CSV document from a PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/filters/FilterController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/filters/FilterController.java index ce9dab8c5..b4e9dc285 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/filters/FilterController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/filters/FilterController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.filters; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import org.apache.pdfbox.pdmodel.PDDocument; @@ -37,7 +39,7 @@ public class FilterController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/filter-contains-text") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-text") @Operation( summary = "Checks if a PDF contains set text, returns true if does", description = "Input:PDF Output:Boolean Type:SISO") @@ -55,7 +57,7 @@ public class FilterController { } // TODO - @PostMapping(consumes = "multipart/form-data", value = "/filter-contains-image") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-image") @Operation( summary = "Checks if a PDF contains an image", description = "Input:PDF Output:Boolean Type:SISO") @@ -71,7 +73,7 @@ public class FilterController { return null; } - @PostMapping(consumes = "multipart/form-data", value = "/filter-page-count") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-count") @Operation( summary = "Checks if a PDF is greater, less or equal to a setPageCount", description = "Input:PDF Output:Boolean Type:SISO") @@ -104,7 +106,7 @@ public class FilterController { return null; } - @PostMapping(consumes = "multipart/form-data", value = "/filter-page-size") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-size") @Operation( summary = "Checks if a PDF is of a certain size", description = "Input:PDF Output:Boolean Type:SISO") @@ -147,7 +149,7 @@ public class FilterController { return null; } - @PostMapping(consumes = "multipart/form-data", value = "/filter-file-size") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-file-size") @Operation( summary = "Checks if a PDF is a set file size", description = "Input:PDF Output:Boolean Type:SISO") @@ -180,7 +182,7 @@ public class FilterController { return null; } - @PostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation") @Operation( summary = "Checks if a PDF is of a certain rotation", description = "Input:PDF Output:Boolean Type:SISO") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java index b36065612..3729af9d8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import java.util.List; @@ -34,7 +36,7 @@ public class AttachmentController { private final AttachmentServiceInterface pdfAttachmentService; - @PostMapping(consumes = "multipart/form-data", value = "/add-attachments") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-attachments") @Operation( summary = "Add attachments to PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java index 8d803708c..628e0d028 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import java.util.ArrayList; import java.util.Comparator; @@ -38,7 +40,7 @@ public class AutoRenameController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/auto-rename") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/auto-rename") @Operation( summary = "Extract header from PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java index 44d575575..0650481bf 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.image.BufferedImage; import java.awt.image.DataBufferByte; import java.awt.image.DataBufferInt; @@ -102,7 +104,7 @@ public class AutoSplitPdfController { } } - @PostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data") @Operation( summary = "Auto split PDF pages into separate documents", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java index 7d5086b4c..010d6d0bb 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -69,7 +71,7 @@ public class BlankPageController { return whitePixelPercentage >= whitePercent; } - @PostMapping(consumes = "multipart/form-data", value = "/remove-blanks") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-blanks") @Operation( summary = "Remove blank pages from a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java index ab8e5b3f8..9b0b43dc1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; @@ -658,7 +660,7 @@ public class CompressController { }; } - @PostMapping(consumes = "multipart/form-data", value = "/compress-pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/compress-pdf") @Operation( summary = "Optimize PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java index 5c432ce57..b5fe504c4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -38,7 +40,7 @@ public class DecompressPdfController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/decompress-pdf", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/decompress-pdf", consumes = "multipart/form-data") @Operation( summary = "Decompress PDF streams", description = "Fully decompresses all PDF streams including text content") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java index 3992595ab..aa3c40519 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.image.BufferedImage; import java.io.FileOutputStream; import java.io.IOException; @@ -50,7 +52,7 @@ public class ExtractImageScansController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/extract-image-scans") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-image-scans") @Operation( summary = "Extract image scans from an input file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java index 09486f9e8..2e2968c9c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.*; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; @@ -54,7 +56,7 @@ public class ExtractImagesController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/extract-images") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-images") @Operation( summary = "Extract images from a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java index d82a1971a..ecd263c1c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.image.BufferedImage; import java.io.IOException; @@ -38,7 +40,7 @@ public class FlattenController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/flatten") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/flatten") @Operation( summary = "Flatten PDF form fields or full page", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java index 1d5196940..37b1c209e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -51,7 +53,7 @@ public class MetadataController { binder.registerCustomEditor(Map.class, "allRequestParams", new StringToMapPropertyEditor()); } - @PostMapping(consumes = "multipart/form-data", value = "/update-metadata") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/update-metadata") @Operation( summary = "Update metadata of a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index c1fd4ade1..5cd80384c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.image.BufferedImage; import java.io.*; import java.nio.file.Files; @@ -76,7 +78,7 @@ public class OCRController { .toList(); } - @PostMapping(consumes = "multipart/form-data", value = "/ocr-pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/ocr-pdf") @Operation( summary = "Process a PDF file with OCR", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java index d50c80967..5b61f66ea 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import org.springframework.http.HttpStatus; @@ -31,7 +33,7 @@ public class OverlayImageController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/add-image") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-image") @Operation( summary = "Overlay image onto a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PageNumbersController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PageNumbersController.java index 4233d11e4..d91c30bae 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PageNumbersController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PageNumbersController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.List; @@ -37,7 +39,7 @@ public class PageNumbersController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/add-page-numbers", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/add-page-numbers", consumes = "multipart/form-data") @Operation( summary = "Add page numbers to a PDF document", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java index fc7b7d298..34ed58540 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.*; import java.awt.image.BufferedImage; import java.awt.print.PageFormat; @@ -37,7 +39,7 @@ import stirling.software.SPDF.model.api.misc.PrintFileRequest; public class PrintFileController { // TODO - // @PostMapping(value = "/print-file", consumes = "multipart/form-data") + // @AutoJobPostMapping(value = "/print-file", consumes = "multipart/form-data") // @Operation( // summary = "Prints PDF/Image file to a set printer", // description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java index 7cde1d078..e1084a457 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -46,7 +48,7 @@ public class RepairController { return endpointConfiguration.isGroupEnabled("qpdf"); } - @PostMapping(consumes = "multipart/form-data", value = "/repair") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/repair") @Operation( summary = "Repair a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ReplaceAndInvertColorController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ReplaceAndInvertColorController.java index 85fb7cfc3..b935d59da 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ReplaceAndInvertColorController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ReplaceAndInvertColorController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import org.springframework.core.io.InputStreamResource; @@ -27,7 +29,7 @@ public class ReplaceAndInvertColorController { private final ReplaceAndInvertColorService replaceAndInvertColorService; - @PostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf") @Operation( summary = "Replace-Invert Color PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java index a94b487b4..a140e9029 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.Color; import java.awt.Graphics2D; import java.awt.RenderingHints; @@ -52,7 +54,7 @@ public class ScannerEffectController { private static final int MAX_IMAGE_HEIGHT = 8192; private static final long MAX_IMAGE_PIXELS = 16_777_216; // 4096x4096 - @PostMapping(value = "/scanner-effect", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/scanner-effect", consumes = "multipart/form-data") @Operation( summary = "Apply scanner effect to PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ShowJavascript.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ShowJavascript.java index 94e9b57c6..709d8bd09 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ShowJavascript.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ShowJavascript.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.nio.charset.StandardCharsets; import java.util.Map; @@ -32,7 +34,7 @@ public class ShowJavascript { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/show-javascript") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/show-javascript") @Operation( summary = "Grabs all JS from a PDF and returns a single JS file with all code", description = "desc. Input:PDF Output:JS Type:SISO") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java index f5bc9dc65..512b241c3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; @@ -52,7 +54,7 @@ public class StampController { private final CustomPDFDocumentFactory pdfDocumentFactory; private final TempFileManager tempFileManager; - @PostMapping(consumes = "multipart/form-data", value = "/add-stamp") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-stamp") @Operation( summary = "Add stamp to a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsController.java index 21fd61d11..d80c35022 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.misc; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; @@ -37,7 +39,7 @@ public class UnlockPDFFormsController { this.pdfDocumentFactory = pdfDocumentFactory; } - @PostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms") @Operation( summary = "Remove read-only property from form fields", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java index d6b4fa0da..166668db9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.pipeline; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.util.HashMap; @@ -46,7 +48,7 @@ public class PipelineController { private final PostHogService postHogService; - @PostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity handleData(@ModelAttribute HandleDataRequest request) throws JsonMappingException, JsonProcessingException { MultipartFile[] files = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 7675355da..b43d918e8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.security; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.*; import java.beans.PropertyEditorSupport; import java.io.*; @@ -138,7 +140,7 @@ public class CertSignController { } } - @PostMapping( + @AutoJobPostMapping( consumes = { MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java index f3c0a5e29..436b2ba30 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.security; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -188,7 +190,7 @@ public class GetInfoOnPDF { return false; } - @PostMapping(consumes = "multipart/form-data", value = "/get-info-on-pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/get-info-on-pdf") @Operation(summary = "Summary here", description = "desc. Input:PDF Output:JSON Type:SISO") public ResponseEntity getPdfInfo(@ModelAttribute PDFFile request) throws IOException { MultipartFile inputFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java index ef382ee44..d3e78ef6e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.security; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import org.apache.pdfbox.pdmodel.PDDocument; @@ -32,7 +34,7 @@ public class PasswordController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/remove-password") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-password") @Operation( summary = "Remove password from a PDF file", description = @@ -58,7 +60,7 @@ public class PasswordController { } } - @PostMapping(consumes = "multipart/form-data", value = "/add-password") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-password") @Operation( summary = "Add password to a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index 88d271cfb..23d7e20ad 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.security; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.*; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -56,7 +58,7 @@ public class RedactController { List.class, "redactions", new StringToArrayListPropertyEditor()); } - @PostMapping(value = "/redact", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/redact", consumes = "multipart/form-data") @Operation( summary = "Redacts areas and pages in a PDF document", description = @@ -190,7 +192,7 @@ public class RedactController { return pageNumbers; } - @PostMapping(value = "/auto-redact", consumes = "multipart/form-data") + @AutoJobPostMapping(value = "/auto-redact", consumes = "multipart/form-data") @Operation( summary = "Redacts listOfText in a PDF document", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java index 79fd18914..8ecfe7cb7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.security; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; @@ -32,7 +34,7 @@ public class RemoveCertSignController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign") @Operation( summary = "Remove digital signature from PDF", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java index 47e45c595..5935a5152 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.security; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.io.IOException; import org.apache.pdfbox.cos.COSDictionary; @@ -46,7 +48,7 @@ public class SanitizeController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf") @Operation( summary = "Sanitize a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java index a98f0c0d1..0ed9e98f9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.security; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.beans.PropertyEditorSupport; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -69,7 +71,7 @@ public class ValidateSignatureController { description = "Validates the digital signatures in a PDF file against default or custom" + " certificates. Input:PDF Output:JSON Type:SISO") - @PostMapping(value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> validateSignature( @ModelAttribute SignatureValidationRequest request) throws IOException { List results = new ArrayList<>(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index 484a1c116..b3abf2df7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.security; +import stirling.software.common.annotations.AutoJobPostMapping; + import java.awt.*; import java.awt.image.BufferedImage; import java.beans.PropertyEditorSupport; @@ -64,7 +66,7 @@ public class WatermarkController { }); } - @PostMapping(consumes = "multipart/form-data", value = "/add-watermark") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-watermark") @Operation( summary = "Add watermark to a PDF file", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java index fbbd4723a..a3ebcfd3d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.model.api.converters; +import stirling.software.common.annotations.AutoJobPostMapping; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; @@ -18,7 +20,7 @@ import stirling.software.common.util.PDFToFile; @RequestMapping("/api/v1/convert") public class ConvertPDFToMarkdown { - @PostMapping(consumes = "multipart/form-data", value = "/pdf/markdown") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/markdown") @Operation( summary = "Convert PDF to Markdown", description = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java index 7fb767573..839c07083 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java @@ -1,5 +1,7 @@ package stirling.software.proprietary.security.controller.api; +import stirling.software.common.annotations.AutoJobPostMapping; + import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -42,7 +44,7 @@ public class EmailController { * attachment. * @return ResponseEntity with success or error message. */ - @PostMapping(consumes = "multipart/form-data", value = "/send-email") + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/send-email") @Operation( summary = "Send an email with an attachment", description = diff --git a/testing/cucumber/features/examples.feature b/testing/cucumber/features/examples.feature index 398a80ce1..a9cf5b41a 100644 --- a/testing/cucumber/features/examples.feature +++ b/testing/cucumber/features/examples.feature @@ -25,7 +25,7 @@ Feature: API Validation | password | wrongPassword | When I send the API request to the endpoint "/api/v1/security/remove-password" Then the response status code should be 500 - And the response should contain error message "Internal Server Error" + And the response should contain error message "Job failed: org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException: Cannot decrypt PDF, the password is incorrect" @positive @info Scenario: Get info From 1bd166f6fb8835acb834c1e02f2f4df9b3c64469 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:38:37 +0100 Subject: [PATCH 07/29] V2 Update PR-Auto-Deploy-V2.yml to use common tag (#4046) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/PR-Auto-Deploy-V2.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index c8fabfd59..35fa7275c 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -261,26 +261,31 @@ jobs: - name: Build and push V2 frontend image if: steps.check-frontend.outputs.exists == 'false' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ./docker/frontend/Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 - + - name: Build and push V2 backend image if: steps.check-backend.outputs.exists == 'false' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ./docker/backend/Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 + - name: Set up SSH run: | mkdir -p ~/.ssh/ From 8989bdb211774b734058148163a57fe1bec3301b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:49:27 +0100 Subject: [PATCH 08/29] Revert "V2 Update PR-Auto-Deploy-V2.yml to use common tag" (#4047) Reverts Stirling-Tools/Stirling-PDF#4046 --- .github/workflows/PR-Auto-Deploy-V2.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 35fa7275c..c8fabfd59 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -261,31 +261,26 @@ jobs: - name: Build and push V2 frontend image if: steps.check-frontend.outputs.exists == 'false' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ./docker/frontend/Dockerfile push: true - tags: | - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 - + - name: Build and push V2 backend image if: steps.check-backend.outputs.exists == 'false' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ./docker/backend/Dockerfile push: true - tags: | - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 - - name: Set up SSH run: | mkdir -p ~/.ssh/ From dbe84f7e96859705e0be4508e39352f5a2276fef Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:50:36 +0100 Subject: [PATCH 09/29] Update deploy-on-v2-commit.yml --- .github/workflows/deploy-on-v2-commit.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index d5e3a7b16..3c9ba85b8 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -98,7 +98,9 @@ jobs: context: . file: ./docker/frontend/Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 @@ -109,7 +111,9 @@ jobs: context: . file: ./docker/backend/Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 From 989052b0997c52be212a88620bece3efb05e8088 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:00:08 +0100 Subject: [PATCH 10/29] Update deploy-on-v2-commit.yml --- .github/workflows/deploy-on-v2-commit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index 3c9ba85b8..8c3218869 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -100,7 +100,7 @@ jobs: push: true tags: | ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 From a6df5e4246952f8d3fb542291c44ce7a84c86d32 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:19:13 +0100 Subject: [PATCH 11/29] Update docker-compose.yml --- docker/compose/docker-compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docker/compose/docker-compose.yml b/docker/compose/docker-compose.yml index 4defef872..b0061f785 100644 --- a/docker/compose/docker-compose.yml +++ b/docker/compose/docker-compose.yml @@ -25,7 +25,6 @@ services: environment: DISABLE_ADDITIONAL_FEATURES: "true" SECURITY_ENABLELOGIN: "false" - LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID" SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: Stirling-PDF UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest @@ -59,4 +58,4 @@ networks: volumes: stirling-data: stirling-config: - stirling-logs: \ No newline at end of file + stirling-logs: From fc89ee2c783ef0fd9efc6fe101aa3a14c128d89d Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:19:28 +0100 Subject: [PATCH 12/29] Update docker-compose.fat.yml --- docker/compose/docker-compose.fat.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docker/compose/docker-compose.fat.yml b/docker/compose/docker-compose.fat.yml index 8399c4080..1757782d5 100644 --- a/docker/compose/docker-compose.fat.yml +++ b/docker/compose/docker-compose.fat.yml @@ -26,8 +26,6 @@ services: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "false" FAT_DOCKER: "true" - INSTALL_BOOK_AND_ADVANCED_HTML_OPS: "false" - LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID" SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: Stirling-PDF UI_HOMEDESCRIPTION: Full-featured Stirling-PDF with all capabilities @@ -61,4 +59,4 @@ networks: volumes: stirling-data: stirling-config: - stirling-logs: \ No newline at end of file + stirling-logs: From 64a471c3a9ec25ab287586529d9deb972029feef Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 30 Jul 2025 23:38:03 +0200 Subject: [PATCH 13/29] fix(workflows): correct YAML indentation in frontend license update workflow (#4056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …orkflow # Description of Changes - Fixed invalid YAML syntax caused by inconsistent indentation inside a multi-line JavaScript template string in `.github/workflows/frontend-licenses-update.yml`. - The incorrect indentation broke the GitHub Actions runner with a syntax error on line 131. - This change ensures that the entire `commentBody = \`...\`;` block is treated as a valid JavaScript string inside the `run:` shell context. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/frontend-licenses-update.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/frontend-licenses-update.yml b/.github/workflows/frontend-licenses-update.yml index b6f37ce3a..ac8676c8a 100644 --- a/.github/workflows/frontend-licenses-update.yml +++ b/.github/workflows/frontend-licenses-update.yml @@ -126,19 +126,19 @@ jobs: commentBody = `## ❌ Frontend License Check Failed -The frontend license check has detected compatibility warnings that require review: + The frontend license check has detected compatibility warnings that require review: -${warningDetails} + ${warningDetails} -**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging. + **Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging. -_This check will fail the PR until license issues are resolved._`; + _This check will fail the PR until license issues are resolved._`; } else { commentBody = `## ✅ Frontend License Check Passed -All frontend licenses have been validated and no compatibility warnings were detected. + All frontend licenses have been validated and no compatibility warnings were detected. -The frontend license report has been updated successfully.`; + The frontend license report has been updated successfully.`; } await github.rest.issues.createComment({ From 8802daf67f987092ca1c85ee348f9a071ff0d107 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:26:20 +0100 Subject: [PATCH 14/29] Support for SSL testing (#4070) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/PR-Auto-Deploy-V2.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index c8fabfd59..7094ef72d 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -386,10 +386,12 @@ jobs: } const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`; - + const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`; + const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` + `Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` + - `🔗 **V2 Test URL:** [${deploymentUrl}](${deploymentUrl})\n\n` + + `🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + + `🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` + `🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`; From 8881f19b030c90696a31700400a437f6fe684053 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 1 Aug 2025 14:22:19 +0100 Subject: [PATCH 15/29] Tools/ocr/v2 (#4055) # Description of Changes - Added the OCR tool - Added language mappings file to map selected browser language -> OCR language and OCR language codes -> english display values. TODO: Use the translation function to translate the languages rather than mapping them to english be default - Added chevron icons to tool step to show expand and collapsed state more visibly - Added a re-usable dropdown picker with a footer component --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/en-GB/translation.json | 2 +- .../shared/DropdownListWithFooter.tsx | 237 ++++ .../shared/LanguageSelector.module.css | 6 +- .../components/shared/LanguageSelector.tsx | 36 +- .../tools/ocr/AdvancedOCRSettings.tsx | 90 ++ .../tools/ocr/LanguagePicker.module.css | 126 ++ .../components/tools/ocr/LanguagePicker.tsx | 151 +++ .../src/components/tools/ocr/OCRSettings.tsx | 54 + .../src/components/tools/shared/ToolStep.tsx | 51 +- .../src/hooks/tools/ocr/useOCROperation.ts | 372 ++++++ .../src/hooks/tools/ocr/useOCRParameters.ts | 43 + frontend/src/hooks/useEndpointConfig.ts | 7 +- frontend/src/hooks/useToolManagement.tsx | 11 + frontend/src/theme/mantineTheme.ts | 33 + frontend/src/tools/OCR.tsx | 215 ++++ frontend/src/types/fileContext.ts | 18 +- frontend/src/utils/languageMapping.ts | 1020 +++++++++++++++++ 17 files changed, 2421 insertions(+), 51 deletions(-) create mode 100644 frontend/src/components/shared/DropdownListWithFooter.tsx create mode 100644 frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx create mode 100644 frontend/src/components/tools/ocr/LanguagePicker.module.css create mode 100644 frontend/src/components/tools/ocr/LanguagePicker.tsx create mode 100644 frontend/src/components/tools/ocr/OCRSettings.tsx create mode 100644 frontend/src/hooks/tools/ocr/useOCROperation.ts create mode 100644 frontend/src/hooks/tools/ocr/useOCRParameters.ts create mode 100644 frontend/src/tools/OCR.tsx create mode 100644 frontend/src/utils/languageMapping.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 37c0e5355..eedcb7885 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1676,4 +1676,4 @@ "storageQuotaExceeded": "Storage quota exceeded. Please remove some files before uploading more.", "approximateSize": "Approximate size" } -} +} \ No newline at end of file diff --git a/frontend/src/components/shared/DropdownListWithFooter.tsx b/frontend/src/components/shared/DropdownListWithFooter.tsx new file mode 100644 index 000000000..368b2255e --- /dev/null +++ b/frontend/src/components/shared/DropdownListWithFooter.tsx @@ -0,0 +1,237 @@ +import React, { ReactNode, useState, useMemo } from 'react'; +import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from '@mantine/core'; +import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; +import SearchIcon from '@mui/icons-material/Search'; + +export interface DropdownItem { + value: string; + name: string; + leftIcon?: ReactNode; + disabled?: boolean; +} + +export interface DropdownListWithFooterProps { + // Value and onChange - support both single and multi-select + value: string | string[]; + onChange: (value: string | string[]) => void; + + // Items and display + items: DropdownItem[]; + placeholder?: string; + disabled?: boolean; + + // Labels and headers + label?: string; + header?: ReactNode; + footer?: ReactNode; + + // Behavior + multiSelect?: boolean; + searchable?: boolean; + maxHeight?: number; + + // Styling + className?: string; + dropdownClassName?: string; + + // Popover props + position?: 'top' | 'bottom' | 'left' | 'right'; + withArrow?: boolean; + width?: 'target' | number; +} + +const DropdownListWithFooter: React.FC = ({ + value, + onChange, + items, + placeholder = 'Select option', + disabled = false, + label, + header, + footer, + multiSelect = false, + searchable = false, + maxHeight = 300, + className = '', + dropdownClassName = '', + position = 'bottom', + withArrow = false, + width = 'target' +}) => { + + const [searchTerm, setSearchTerm] = useState(''); + + const isMultiValue = Array.isArray(value); + const selectedValues = isMultiValue ? value : (value ? [value] : []); + + // Filter items based on search term + const filteredItems = useMemo(() => { + if (!searchable || !searchTerm.trim()) { + return items; + } + return items.filter(item => + item.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [items, searchTerm, searchable]); + + const handleItemClick = (itemValue: string) => { + if (multiSelect) { + const newSelection = selectedValues.includes(itemValue) + ? selectedValues.filter(v => v !== itemValue) + : [...selectedValues, itemValue]; + onChange(newSelection); + } else { + onChange(itemValue); + } + }; + + const getDisplayText = () => { + if (selectedValues.length === 0) { + return placeholder; + } else if (selectedValues.length === 1) { + const selectedItem = items.find(item => item.value === selectedValues[0]); + return selectedItem?.name || selectedValues[0]; + } else { + return `${selectedValues.length} selected`; + } + }; + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchTerm(event.currentTarget.value); + }; + + return ( + + {label && ( + + {label} + + )} + + searchable && setSearchTerm('')} + > + + + + {getDisplayText()} + + + + + + + + {header && ( + + {header} + + )} + + {searchable && ( + + } + size="sm" + style={{ width: '100%' }} + /> + + )} + + + {filteredItems.length === 0 ? ( + + + {searchable && searchTerm ? 'No results found' : 'No items available'} + + + ) : ( + filteredItems.map((item) => ( + !item.disabled && handleItemClick(item.value)} + style={{ + padding: '8px 12px', + cursor: item.disabled ? 'not-allowed' : 'pointer', + borderRadius: 'var(--mantine-radius-sm)', + opacity: item.disabled ? 0.5 : 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between' + }} + onMouseEnter={(e) => { + if (!item.disabled) { + e.currentTarget.style.backgroundColor = 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))'; + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + + {item.leftIcon && ( + + {item.leftIcon} + + )} + {item.name} + + + {multiSelect && ( + {}} // Handled by parent onClick + size="sm" + disabled={item.disabled} + /> + )} + + )) + )} + + + {footer && ( + + {footer} + + )} + + + + + ); +}; + +export default DropdownListWithFooter; \ No newline at end of file diff --git a/frontend/src/components/shared/LanguageSelector.module.css b/frontend/src/components/shared/LanguageSelector.module.css index 09010dc4a..431f43806 100644 --- a/frontend/src/components/shared/LanguageSelector.module.css +++ b/frontend/src/components/shared/LanguageSelector.module.css @@ -44,7 +44,7 @@ /* Dark theme support */ [data-mantine-color-scheme="dark"] .languageItem { - border-right-color: var(--mantine-color-dark-4); + border-right-color: var(--mantine-color-dark-3); } [data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) { @@ -52,11 +52,11 @@ } [data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) { - border-right-color: var(--mantine-color-dark-4); + border-right-color: var(--mantine-color-dark-3); } [data-mantine-color-scheme="dark"] .languageItem:nth-child(3n) { - border-right-color: var(--mantine-color-dark-4); + border-right-color: var(--mantine-color-dark-3); } /* Responsive text visibility */ diff --git a/frontend/src/components/shared/LanguageSelector.tsx b/frontend/src/components/shared/LanguageSelector.tsx index 83cecc6b0..bd6269b8e 100644 --- a/frontend/src/components/shared/LanguageSelector.tsx +++ b/frontend/src/components/shared/LanguageSelector.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core'; +import { Menu, Button, ScrollArea } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { supportedLanguages } from '../../i18n'; import LanguageIcon from '@mui/icons-material/Language'; @@ -7,8 +7,6 @@ import styles from './LanguageSelector.module.css'; const LanguageSelector = () => { const { i18n } = useTranslation(); - const theme = useMantineTheme(); - const { colorScheme } = useMantineColorScheme(); const [opened, setOpened] = useState(false); const [animationTriggered, setAnimationTriggered] = useState(false); const [isChanging, setIsChanging] = useState(false); @@ -102,10 +100,10 @@ const LanguageSelector = () => { styles={{ root: { border: 'none', - color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7], + color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))', transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', '&:hover': { - backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], + backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', } }, label: { @@ -125,7 +123,8 @@ const LanguageSelector = () => { padding: '12px', borderRadius: '8px', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', - border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`, + backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))', + border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))', }} > @@ -145,6 +144,7 @@ const LanguageSelector = () => { size="sm" fullWidth onClick={(event) => handleLanguageChange(option.value, event)} + data-selected={option.value === i18n.language} styles={{ root: { borderRadius: '4px', @@ -153,21 +153,17 @@ const LanguageSelector = () => { justifyContent: 'flex-start', position: 'relative', overflow: 'hidden', - backgroundColor: option.value === i18n.language ? ( - colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1] - ) : 'transparent', - color: option.value === i18n.language ? ( - colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7] - ) : ( - colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7] - ), + backgroundColor: option.value === i18n.language + ? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))' + : 'transparent', + color: option.value === i18n.language + ? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))' + : 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))', transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', '&:hover': { - backgroundColor: option.value === i18n.language ? ( - colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2] - ) : ( - colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1] - ), + backgroundColor: option.value === i18n.language + ? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))' + : 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', transform: 'translateY(-1px)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', } @@ -197,7 +193,7 @@ const LanguageSelector = () => { width: 0, height: 0, borderRadius: '50%', - backgroundColor: theme.colors.blue[4], + backgroundColor: 'var(--mantine-color-blue-4)', opacity: 0.6, transform: 'translate(-50%, -50%)', animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)', diff --git a/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx b/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx new file mode 100644 index 000000000..3bd8c1569 --- /dev/null +++ b/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Stack, Text, Checkbox } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { OCRParameters } from './OCRSettings'; + +export interface AdvancedOCRParameters { + advancedOptions: string[]; +} + +interface AdvancedOption { + value: string; + label: string; + isSpecial: boolean; +} + +interface AdvancedOCRSettingsProps { + advancedOptions: string[]; + ocrRenderType?: string; + onParameterChange: (key: keyof OCRParameters, value: any) => void; + disabled?: boolean; +} + +const AdvancedOCRSettings: React.FC = ({ + advancedOptions, + ocrRenderType = 'hocr', + onParameterChange, + disabled = false +}) => { + const { t } = useTranslation(); + + // Define the advanced options available + const advancedOptionsData: AdvancedOption[] = [ + { value: 'compatibilityMode', label: t('ocr.settings.compatibilityMode.label', 'Compatibility Mode'), isSpecial: true }, + { value: 'sidecar', label: t('ocr.settings.advancedOptions.sidecar', 'Create a text file'), isSpecial: false }, + { value: 'deskew', label: t('ocr.settings.advancedOptions.deskew', 'Deskew pages'), isSpecial: false }, + { value: 'clean', label: t('ocr.settings.advancedOptions.clean', 'Clean input file'), isSpecial: false }, + { value: 'cleanFinal', label: t('ocr.settings.advancedOptions.cleanFinal', 'Clean final output'), isSpecial: false }, + ]; + + // Handle individual checkbox changes + const handleCheckboxChange = (optionValue: string, checked: boolean) => { + const option = advancedOptionsData.find(opt => opt.value === optionValue); + + if (option?.isSpecial) { + // Handle special options (like compatibility mode) differently + if (optionValue === 'compatibilityMode') { + onParameterChange('ocrRenderType', checked ? 'sandwich' : 'hocr'); + } + } else { + // Handle regular advanced options + const newOptions = checked + ? [...advancedOptions, optionValue] + : advancedOptions.filter(option => option !== optionValue); + onParameterChange('additionalOptions', newOptions); + } + }; + + // Check if a special option is selected + const isSpecialOptionSelected = (optionValue: string) => { + if (optionValue === 'compatibilityMode') { + return ocrRenderType === 'sandwich'; + } + return false; + }; + + return ( + +
+ + {t('ocr.settings.advancedOptions.label', 'Processing Options')} + + + + {advancedOptionsData.map((option) => ( + handleCheckboxChange(option.value, event.currentTarget.checked)} + label={option.label} + disabled={disabled} + size="sm" + /> + ))} + +
+
+ ); +}; + +export default AdvancedOCRSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/ocr/LanguagePicker.module.css b/frontend/src/components/tools/ocr/LanguagePicker.module.css new file mode 100644 index 000000000..c44e75291 --- /dev/null +++ b/frontend/src/components/tools/ocr/LanguagePicker.module.css @@ -0,0 +1,126 @@ +/* Language Picker Component */ +.languagePicker { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; /* Center align items vertically */ + height: 32px; + border: 1px solid var(--border-default); + background-color: var(--mantine-color-white); /* Use Mantine color variable */ + color: var(--text-secondary); + border-radius: var(--radius-sm); + padding: 4px 8px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; +} + +/* Dark mode background */ +[data-mantine-color-scheme="dark"] .languagePicker { + background-color: var(--mantine-color-dark-6); /* Use Mantine dark color instead of hardcoded */ +} + +.languagePicker:hover { + border-color: var(--border-strong); + background-color: var(--mantine-color-gray-0); /* Light gray on hover for light mode */ +} + +/* Dark mode hover */ +[data-mantine-color-scheme="dark"] .languagePicker:hover { + background-color: var(--mantine-color-dark-5); /* Use Mantine color variable */ +} + +.languagePicker:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.languagePickerIcon { + font-size: 16px; + color: var(--text-muted); + margin-left: auto; + display: flex; + align-items: center; /* Center the icon vertically */ +} + +.languagePickerDropdown { + background-color: var(--mantine-color-white); /* Use Mantine color variable */ + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + padding: 4px; +} + +/* Dark mode dropdown background */ +[data-mantine-color-scheme="dark"] .languagePickerDropdown { + background-color: var(--mantine-color-dark-6); +} + +.languagePickerOption { + padding: 6px 10px; + cursor: pointer; + border-radius: var(--radius-xs); + font-size: 13px; + color: var(--text-primary); + transition: background-color 0.2s ease; +} + +.languagePickerOptionWithCheckbox { + display: flex; + align-items: center; + justify-content: space-between; +} + +.languagePickerCheckbox { + margin-left: auto; +} + +.languagePickerOption:hover { + background-color: var(--mantine-color-gray-0); /* Light gray on hover for light mode */ +} + +/* Dark mode option hover */ +[data-mantine-color-scheme="dark"] .languagePickerOption:hover { + background-color: var(--mantine-color-dark-5); +} + + + +/* Additional helper classes for the component */ +.languagePickerTarget { + width: 100%; +} + +.languagePickerContent { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.languagePickerText { + flex: 1; + text-align: left; +} + +.languagePickerScrollArea { + max-height: 180px; + border-bottom: 1px solid var(--border-default); + padding-bottom: 8px; +} + +.languagePickerFooter { + padding: 8px; + text-align: center; + font-size: 12px; +} + +.languagePickerLink { + color: var(--mantine-color-blue-6); + text-decoration: underline; + cursor: pointer; +} + +/* Dark mode link */ +[data-mantine-color-scheme="dark"] .languagePickerLink { + color: var(--mantine-color-blue-4); +} \ No newline at end of file diff --git a/frontend/src/components/tools/ocr/LanguagePicker.tsx b/frontend/src/components/tools/ocr/LanguagePicker.tsx new file mode 100644 index 000000000..31f0fe301 --- /dev/null +++ b/frontend/src/components/tools/ocr/LanguagePicker.tsx @@ -0,0 +1,151 @@ +import React, { useState, useEffect } from 'react'; +import { Text, Loader } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { tempOcrLanguages, getAutoOcrLanguage } from '../../../utils/languageMapping'; +import DropdownListWithFooter, { DropdownItem } from '../../shared/DropdownListWithFooter'; + +export interface LanguageOption { + value: string; + label: string; +} + +export interface LanguagePickerProps { + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; + disabled?: boolean; + label?: string; + languagesEndpoint?: string; + autoFillFromBrowserLanguage?: boolean; +} + +const LanguagePicker: React.FC = ({ + value, + onChange, + placeholder = 'Select languages', + disabled = false, + label, + languagesEndpoint = '/api/v1/ui-data/ocr-pdf', + autoFillFromBrowserLanguage = true, +}) => { + const { t, i18n } = useTranslation(); + const [availableLanguages, setAvailableLanguages] = useState([]); + const [isLoadingLanguages, setIsLoadingLanguages] = useState(true); + const [hasAutoFilled, setHasAutoFilled] = useState(false); + + useEffect(() => { + // Fetch available languages from backend + const fetchLanguages = async () => { + try { + const response = await fetch(languagesEndpoint); + + + if (response.ok) { + const data: { languages: string[] } = await response.json(); + const languages = data.languages; + + + const languageOptions = languages.map(lang => { + // TODO: Use actual language translations when they become available + // For now, use temporary English translations + const translatedName = tempOcrLanguages.lang[lang as keyof typeof tempOcrLanguages.lang] || lang; + const displayName = translatedName; + + return { + value: lang, + name: displayName + }; + }); + + setAvailableLanguages(languageOptions); + } else { + console.error('[LanguagePicker] Response not OK:', response.status, response.statusText); + const errorText = await response.text(); + console.error('[LanguagePicker] Error response body:', errorText); + } + } catch (error) { + console.error('[LanguagePicker] Fetch failed with error:', error); + console.error('[LanguagePicker] Error details:', { + name: error instanceof Error ? error.name : 'Unknown', + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + } finally { + setIsLoadingLanguages(false); + } + }; + + fetchLanguages(); + }, [languagesEndpoint]); + + // Auto-fill OCR language based on browser language when languages are loaded + useEffect(() => { + const shouldAutoFillLanguage = autoFillFromBrowserLanguage && !isLoadingLanguages && availableLanguages.length > 0 && !hasAutoFilled && value.length === 0; + + if (shouldAutoFillLanguage) { + // Use the comprehensive language mapping from languageMapping.ts + const suggestedOcrLanguages = getAutoOcrLanguage(i18n.language); + + if (suggestedOcrLanguages.length > 0) { + // Find the first suggested language that's available in the backend + const matchingLanguage = availableLanguages.find(lang => + suggestedOcrLanguages.includes(lang.value) + ); + + if (matchingLanguage) { + onChange([matchingLanguage.value]); + } + } + + setHasAutoFilled(true); + } + }, [autoFillFromBrowserLanguage, isLoadingLanguages, availableLanguages, hasAutoFilled, value.length, i18n.language, onChange]); + + if (isLoadingLanguages) { + return ( +
+ + Loading available languages... +
+ ); + } + + const footer = ( + <> +
+ + {t('ocr.languagePicker.additionalLanguages', 'Looking for additional languages?')} + + window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')} + > + {t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')} + +
+ + ); + + return ( + onChange(newValue as string[])} + items={availableLanguages} + placeholder={placeholder} + disabled={disabled} + label={label} + footer={footer} + multiSelect={true} + maxHeight={300} + searchable={true} + /> + ); +}; + +export default LanguagePicker; \ No newline at end of file diff --git a/frontend/src/components/tools/ocr/OCRSettings.tsx b/frontend/src/components/tools/ocr/OCRSettings.tsx new file mode 100644 index 000000000..588884889 --- /dev/null +++ b/frontend/src/components/tools/ocr/OCRSettings.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Stack, Select, Text, Divider } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LanguagePicker from './LanguagePicker'; + +export interface OCRParameters { + languages: string[]; + ocrType: string; + ocrRenderType: string; + additionalOptions: string[]; +} + +interface OCRSettingsProps { + parameters: OCRParameters; + onParameterChange: (key: keyof OCRParameters, value: any) => void; + disabled?: boolean; +} + +const OCRSettings: React.FC = ({ + parameters, + onParameterChange, + disabled = false +}) => { + const { t } = useTranslation(); + + return ( + + + val && onParameterChange('imageOptions', { + ...parameters.imageOptions, + colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES] + })} + data={[ + { value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") }, + { value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") }, + { value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") }, + ]} + disabled={disabled} + /> + + val && onParameterChange('imageOptions', { + ...parameters.imageOptions, + colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES] + })} + data={[ + { value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") }, + { value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") }, + { value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") }, + ]} + disabled={disabled} + /> + typeof val === 'number' && onParameterChange('imageOptions', { + ...parameters.imageOptions, + dpi: val + })} + min={72} + max={600} + step={1} + disabled={disabled} + /> + + onParameterChange('pdfaOptions', { + ...parameters.pdfaOptions, + outputFormat: value || 'pdfa-1' + })} + data={pdfaFormatOptions} + disabled={disabled || isChecking} + data-testid="pdfa-output-format-select" + /> + + {t("convert.pdfaNote", "PDF/A-1b is more compatible, PDF/A-2b supports more features.")} + + + + ); +}; + +export default ConvertToPdfaSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/convert/GroupedFormatDropdown.tsx b/frontend/src/components/tools/convert/GroupedFormatDropdown.tsx new file mode 100644 index 000000000..6ef1c25a3 --- /dev/null +++ b/frontend/src/components/tools/convert/GroupedFormatDropdown.tsx @@ -0,0 +1,156 @@ +import React, { useState, useMemo } from "react"; +import { Stack, Text, Group, Button, Box, Popover, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; + +interface FormatOption { + value: string; + label: string; + group: string; + enabled?: boolean; +} + +interface GroupedFormatDropdownProps { + value?: string; + placeholder?: string; + options: FormatOption[]; + onChange: (value: string) => void; + disabled?: boolean; + minWidth?: string; + name?: string; +} + +const GroupedFormatDropdown = ({ + value, + placeholder = "Select an option", + options, + onChange, + disabled = false, + minWidth = "18.75rem", + name +}: GroupedFormatDropdownProps) => { + const [dropdownOpened, setDropdownOpened] = useState(false); + const theme = useMantineTheme(); + const { colorScheme } = useMantineColorScheme(); + + const groupedOptions = useMemo(() => { + const groups: Record = {}; + + options.forEach(option => { + if (!groups[option.group]) { + groups[option.group] = []; + } + groups[option.group].push(option); + }); + + return groups; + }, [options]); + + const selectedLabel = useMemo(() => { + if (!value) return placeholder; + const selected = options.find(opt => opt.value === value); + return selected ? `${selected.group} (${selected.label})` : value.toUpperCase(); + }, [value, options, placeholder]); + + const handleOptionSelect = (selectedValue: string) => { + onChange(selectedValue); + setDropdownOpened(false); + }; + + return ( + setDropdownOpened(false)} + position="bottom-start" + withArrow + shadow="sm" + disabled={disabled} + closeOnEscape={true} + trapFocus + > + + setDropdownOpened(!dropdownOpened)} + disabled={disabled} + style={{ + padding: '0.5rem 0.75rem', + border: `0.0625rem solid ${theme.colors.gray[4]}`, + borderRadius: theme.radius.sm, + backgroundColor: disabled + ? theme.colors.gray[1] + : colorScheme === 'dark' + ? theme.colors.dark[6] + : theme.white, + cursor: disabled ? 'not-allowed' : 'pointer', + width: '100%', + color: disabled + ? colorScheme === 'dark' ? theme.colors.dark[1] : theme.colors.dark[7] + : colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.dark[9] + }} + > + + + {selectedLabel} + + + + + + + + {Object.entries(groupedOptions).map(([groupName, groupOptions]) => ( + + + {groupName} + + + {groupOptions.map((option) => ( + + ))} + + + ))} + + + + ); +}; + +export default GroupedFormatDropdown; \ No newline at end of file diff --git a/frontend/src/components/tools/shared/OperationButton.tsx b/frontend/src/components/tools/shared/OperationButton.tsx index c356b2cc2..094a5b6d3 100644 --- a/frontend/src/components/tools/shared/OperationButton.tsx +++ b/frontend/src/components/tools/shared/OperationButton.tsx @@ -13,6 +13,7 @@ export interface OperationButtonProps { fullWidth?: boolean; mt?: string; type?: 'button' | 'submit' | 'reset'; + 'data-testid'?: string; } const OperationButton = ({ @@ -25,7 +26,8 @@ const OperationButton = ({ color = 'blue', fullWidth = true, mt = 'md', - type = 'button' + type = 'button', + 'data-testid': dataTestId }: OperationButtonProps) => { const { t } = useTranslation(); @@ -39,6 +41,7 @@ const OperationButton = ({ disabled={disabled} variant={variant} color={color} + data-testid={dataTestId} > {isLoading ? (loadingText || t("loading", "Loading...")) diff --git a/frontend/src/components/tools/shared/ResultsPreview.tsx b/frontend/src/components/tools/shared/ResultsPreview.tsx index c13b0bac3..9bddb5dc9 100644 --- a/frontend/src/components/tools/shared/ResultsPreview.tsx +++ b/frontend/src/components/tools/shared/ResultsPreview.tsx @@ -37,28 +37,29 @@ const ResultsPreview = ({ } return ( - + {title && ( - + {title} ({files.length} files) )} {isGeneratingThumbnails ? ( -
+
{loadingMessage}
) : ( - + {files.map((result, index) => ( onFileClick?.(result.file)} + data-testid={`results-preview-thumbnail-${index}`} style={{ textAlign: 'center', height: '10rem', diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index 1d64a25a3..c4f144bfc 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -43,7 +43,7 @@ const ToolStep = ({ return parent ? parent.visibleStepCount >= 3 : false; }, [showNumber, parent]); - const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1; + const stepNumber = parent?.getStepNumber?.() || 1; return ( = { + 'any': ['pdf'], // Mixed files always convert to PDF + 'image': ['pdf'], // Multiple images always convert to PDF + 'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa'], + 'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'], + 'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'], + 'pptx': ['pdf'], 'ppt': ['pdf'], 'odp': ['pdf'], + 'jpg': ['pdf'], 'jpeg': ['pdf'], 'png': ['pdf'], 'gif': ['pdf'], 'bmp': ['pdf'], 'tiff': ['pdf'], 'webp': ['pdf'], 'svg': ['pdf'], + 'html': ['pdf'], + 'zip': ['pdf'], + 'md': ['pdf'], + 'txt': ['pdf'], 'rtf': ['pdf'], + 'eml': ['pdf'] +}; + +// Map extensions to endpoint keys +export const EXTENSION_TO_ENDPOINT: Record> = { + 'any': { 'pdf': 'file-to-pdf' }, // Mixed files use file-to-pdf endpoint + 'image': { 'pdf': 'img-to-pdf' }, // Multiple images use img-to-pdf endpoint + 'pdf': { + 'png': 'pdf-to-img', 'jpg': 'pdf-to-img', 'gif': 'pdf-to-img', 'tiff': 'pdf-to-img', 'bmp': 'pdf-to-img', 'webp': 'pdf-to-img', + 'docx': 'pdf-to-word', 'odt': 'pdf-to-word', + 'pptx': 'pdf-to-presentation', 'odp': 'pdf-to-presentation', + 'csv': 'pdf-to-csv', + 'txt': 'pdf-to-text', 'rtf': 'pdf-to-text', 'md': 'pdf-to-markdown', + 'html': 'pdf-to-html', 'xml': 'pdf-to-xml', + 'pdfa': 'pdf-to-pdfa' + }, + 'docx': { 'pdf': 'file-to-pdf' }, 'doc': { 'pdf': 'file-to-pdf' }, 'odt': { 'pdf': 'file-to-pdf' }, + 'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' }, + 'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' }, + 'jpg': { 'pdf': 'img-to-pdf' }, 'jpeg': { 'pdf': 'img-to-pdf' }, 'png': { 'pdf': 'img-to-pdf' }, + 'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' }, 'svg': { 'pdf': 'img-to-pdf' }, + 'html': { 'pdf': 'html-to-pdf' }, + 'zip': { 'pdf': 'html-to-pdf' }, + 'md': { 'pdf': 'markdown-to-pdf' }, + 'txt': { 'pdf': 'file-to-pdf' }, 'rtf': { 'pdf': 'file-to-pdf' }, + 'eml': { 'pdf': 'eml-to-pdf' } +}; + +export type ColorType = typeof COLOR_TYPES[keyof typeof COLOR_TYPES]; +export type OutputOption = typeof OUTPUT_OPTIONS[keyof typeof OUTPUT_OPTIONS]; +export type FitOption = typeof FIT_OPTIONS[keyof typeof FIT_OPTIONS]; \ No newline at end of file diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts new file mode 100644 index 000000000..3e12ec9e8 --- /dev/null +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -0,0 +1,425 @@ +import { useCallback, useState, useEffect } from 'react'; +import axios from 'axios'; +import { useTranslation } from 'react-i18next'; +import { useFileContext } from '../../../contexts/FileContext'; +import { FileOperation } from '../../../types/fileContext'; +import { generateThumbnailForFile } from '../../../utils/thumbnailUtils'; +import { ConvertParameters } from './useConvertParameters'; +import { detectFileExtension } from '../../../utils/fileUtils'; +import { createFileFromApiResponse } from '../../../utils/fileResponseUtils'; + +import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils'; + +export interface ConvertOperationHook { + executeOperation: ( + parameters: ConvertParameters, + selectedFiles: File[] + ) => Promise; + + // Flattened result properties for cleaner access + files: File[]; + thumbnails: string[]; + isGeneratingThumbnails: boolean; + downloadUrl: string | null; + downloadFilename: string; + status: string; + errorMessage: string | null; + isLoading: boolean; + + // Result management functions + resetResults: () => void; + clearError: () => void; +} + +const shouldProcessFilesSeparately = ( + selectedFiles: File[], + parameters: ConvertParameters +): boolean => { + return selectedFiles.length > 1 && ( + // Image to PDF with combineImages = false + ((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') && + parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) || + // PDF to image conversions (each PDF should generate its own image file) + (parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) || + // PDF to PDF/A conversions (each PDF should be processed separately) + (parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') || + // Web files to PDF conversions (each web file should generate its own PDF) + ((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') && + parameters.toExtension === 'pdf') || + // Web files smart detection + (parameters.isSmartDetection && parameters.smartDetectionType === 'web') || + // Mixed file types (smart detection) + (parameters.isSmartDetection && parameters.smartDetectionType === 'mixed') + ); +}; + +const createFileFromResponse = ( + responseData: any, + headers: any, + originalFileName: string, + targetExtension: string +): File => { + const originalName = originalFileName.split('.')[0]; + const fallbackFilename = `${originalName}_converted.${targetExtension}`; + + return createFileFromApiResponse(responseData, headers, fallbackFilename); +}; + +const generateThumbnailsForFiles = async (files: File[]): Promise => { + const thumbnails: string[] = []; + + for (const file of files) { + try { + const thumbnail = await generateThumbnailForFile(file); + thumbnails.push(thumbnail); + } catch (error) { + thumbnails.push(''); + } + } + + return thumbnails; +}; + +const createDownloadInfo = async (files: File[]): Promise<{ url: string; filename: string }> => { + if (files.length === 1) { + const url = window.URL.createObjectURL(files[0]); + return { url, filename: files[0].name }; + } else { + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + files.forEach(file => { + zip.file(file.name, file); + }); + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const zipUrl = window.URL.createObjectURL(zipBlob); + + return { url: zipUrl, filename: 'converted_files.zip' }; + } +}; + +export const useConvertOperation = (): ConvertOperationHook => { + const { t } = useTranslation(); + const { + recordOperation, + markOperationApplied, + markOperationFailed, + addFiles + } = useFileContext(); + + const [files, setFiles] = useState([]); + const [thumbnails, setThumbnails] = useState([]); + const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadFilename, setDownloadFilename] = useState(''); + const [status, setStatus] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const buildFormData = useCallback(( + parameters: ConvertParameters, + selectedFiles: File[] + ) => { + const formData = new FormData(); + + selectedFiles.forEach(file => { + formData.append("fileInput", file); + }); + + const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions } = parameters; + + if (isImageFormat(toExtension)) { + formData.append("imageFormat", toExtension); + formData.append("colorType", imageOptions.colorType); + formData.append("dpi", imageOptions.dpi.toString()); + formData.append("singleOrMultiple", imageOptions.singleOrMultiple); + } else if (fromExtension === 'pdf' && ['docx', 'odt'].includes(toExtension)) { + formData.append("outputFormat", toExtension); + } else if (fromExtension === 'pdf' && ['pptx', 'odp'].includes(toExtension)) { + formData.append("outputFormat", toExtension); + } else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) { + formData.append("outputFormat", toExtension); + } else if ((isImageFormat(fromExtension) || fromExtension === 'image') && toExtension === 'pdf') { + formData.append("fitOption", imageOptions.fitOption); + formData.append("colorType", imageOptions.colorType); + formData.append("autoRotate", imageOptions.autoRotate.toString()); + } else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') { + formData.append("zoom", htmlOptions.zoomLevel.toString()); + } else if (fromExtension === 'eml' && toExtension === 'pdf') { + formData.append("includeAttachments", emailOptions.includeAttachments.toString()); + formData.append("maxAttachmentSizeMB", emailOptions.maxAttachmentSizeMB.toString()); + formData.append("downloadHtml", emailOptions.downloadHtml.toString()); + formData.append("includeAllRecipients", emailOptions.includeAllRecipients.toString()); + } else if (fromExtension === 'pdf' && toExtension === 'pdfa') { + formData.append("outputFormat", pdfaOptions.outputFormat); + } else if (fromExtension === 'pdf' && toExtension === 'csv') { + formData.append("pageNumbers", "all"); + } + + return formData; + }, []); + + const createOperation = useCallback(( + parameters: ConvertParameters, + selectedFiles: File[] + ): { operation: FileOperation; operationId: string; fileId: string } => { + const operationId = `convert-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const fileId = selectedFiles[0].name; + + const operation: FileOperation = { + id: operationId, + type: 'convert', + timestamp: Date.now(), + fileIds: selectedFiles.map(f => f.name), + status: 'pending', + metadata: { + originalFileName: selectedFiles[0].name, + parameters: { + fromExtension: parameters.fromExtension, + toExtension: parameters.toExtension, + imageOptions: parameters.imageOptions, + htmlOptions: parameters.htmlOptions, + emailOptions: parameters.emailOptions, + pdfaOptions: parameters.pdfaOptions, + }, + fileSize: selectedFiles[0].size + } + }; + + return { operation, operationId, fileId }; + }, []); + + const processResults = useCallback(async (blob: Blob, filename: string) => { + try { + // For single file conversions, create a file directly + const convertedFile = new File([blob], filename, { type: blob.type }); + + // Set local state for preview + setFiles([convertedFile]); + setThumbnails([]); + setIsGeneratingThumbnails(true); + + // Add converted file to FileContext for future use + await addFiles([convertedFile]); + + // Generate thumbnail for preview + try { + const thumbnail = await generateThumbnailForFile(convertedFile); + setThumbnails([thumbnail]); + } catch (error) { + console.warn(`Failed to generate thumbnail for ${filename}:`, error); + setThumbnails(['']); + } + + setIsGeneratingThumbnails(false); + } catch (error) { + console.warn('Failed to process conversion result:', error); + } + }, [addFiles]); + + const executeOperation = useCallback(async ( + parameters: ConvertParameters, + selectedFiles: File[] + ) => { + if (selectedFiles.length === 0) { + setStatus(t("noFileSelected")); + return; + } + + if (shouldProcessFilesSeparately(selectedFiles, parameters)) { + await executeMultipleSeparateFiles(parameters, selectedFiles); + } else { + await executeSingleCombinedOperation(parameters, selectedFiles); + } + }, [t]); + + const executeMultipleSeparateFiles = async ( + parameters: ConvertParameters, + selectedFiles: File[] + ) => { + setStatus(t("loading")); + setIsLoading(true); + setErrorMessage(null); + + const results: File[] = []; + + try { + // Process each file separately + for (let i = 0; i < selectedFiles.length; i++) { + const file = selectedFiles[i]; + setStatus(t("convert.processingFile", `Processing file ${i + 1} of ${selectedFiles.length}...`)); + + const fileExtension = detectFileExtension(file.name); + let endpoint = getEndpointUrl(fileExtension, parameters.toExtension); + let fileSpecificParams = { ...parameters, fromExtension: fileExtension }; + if (!endpoint && parameters.toExtension === 'pdf') { + endpoint = '/api/v1/convert/file/pdf'; + console.log(`Using file-to-pdf fallback for ${fileExtension} file: ${file.name}`); + } + + if (!endpoint) { + console.error(`No endpoint available for ${fileExtension} to ${parameters.toExtension}`); + continue; + } + + const { operation, operationId, fileId } = createOperation(fileSpecificParams, [file]); + const formData = buildFormData(fileSpecificParams, [file]); + + recordOperation(fileId, operation); + + try { + const response = await axios.post(endpoint, formData, { responseType: "blob" }); + + // Use utility function to create file from response + const convertedFile = createFileFromResponse( + response.data, + response.headers, + file.name, + parameters.toExtension + ); + results.push(convertedFile); + + markOperationApplied(fileId, operationId); + } catch (error: any) { + console.error(`Error converting file ${file.name}:`, error); + markOperationFailed(fileId, operationId); + } + } + + if (results.length > 0) { + + const generatedThumbnails = await generateThumbnailsForFiles(results); + + setFiles(results); + setThumbnails(generatedThumbnails); + + await addFiles(results); + + try { + const { url, filename } = await createDownloadInfo(results); + setDownloadUrl(url); + setDownloadFilename(filename); + } catch (error) { + console.error('Failed to create download info:', error); + const url = window.URL.createObjectURL(results[0]); + setDownloadUrl(url); + setDownloadFilename(results[0].name); + } + setStatus(t("convert.multipleFilesComplete", `Converted ${results.length} files successfully`)); + } else { + setErrorMessage(t("convert.errorAllFilesFailed", "All files failed to convert")); + } + } catch (error) { + console.error('Error in multiple operations:', error); + setErrorMessage(t("convert.errorMultipleConversion", "An error occurred while converting multiple files")); + } finally { + setIsLoading(false); + } + }; + + const executeSingleCombinedOperation = async ( + parameters: ConvertParameters, + selectedFiles: File[] + ) => { + const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); + const formData = buildFormData(parameters, selectedFiles); + + // Get endpoint using utility function + const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension); + if (!endpoint) { + setErrorMessage(t("convert.errorNotSupported", { from: parameters.fromExtension, to: parameters.toExtension })); + return; + } + + recordOperation(fileId, operation); + + setStatus(t("loading")); + setIsLoading(true); + setErrorMessage(null); + + try { + const response = await axios.post(endpoint, formData, { responseType: "blob" }); + + // Use utility function to create file from response + const originalFileName = selectedFiles.length === 1 + ? selectedFiles[0].name + : 'combined_files.pdf'; // Default extension for combined files + + const convertedFile = createFileFromResponse( + response.data, + response.headers, + originalFileName, + parameters.toExtension + ); + + const url = window.URL.createObjectURL(convertedFile); + setDownloadUrl(url); + setDownloadFilename(convertedFile.name); + setStatus(t("downloadComplete")); + + await processResults(new Blob([convertedFile]), convertedFile.name); + markOperationApplied(fileId, operationId); + } catch (error: any) { + console.error(error); + let errorMsg = t("convert.errorConversion", "An error occurred while converting the file."); + if (error.response?.data && typeof error.response.data === 'string') { + errorMsg = error.response.data; + } else if (error.message) { + errorMsg = error.message; + } + setErrorMessage(errorMsg); + markOperationFailed(fileId, operationId, errorMsg); + } finally { + setIsLoading(false); + } + }; + + + const resetResults = useCallback(() => { + // Clean up blob URLs to prevent memory leaks + if (downloadUrl) { + window.URL.revokeObjectURL(downloadUrl); + } + + setFiles([]); + setThumbnails([]); + setIsGeneratingThumbnails(false); + setDownloadUrl(null); + setDownloadFilename(''); + setStatus(''); + setErrorMessage(null); + setIsLoading(false); + }, [downloadUrl]); + + const clearError = useCallback(() => { + setErrorMessage(null); + }, []); + + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (downloadUrl) { + window.URL.revokeObjectURL(downloadUrl); + } + }; + }, [downloadUrl]); + + return { + executeOperation, + + // Flattened result properties for cleaner access + files, + thumbnails, + isGeneratingThumbnails, + downloadUrl, + downloadFilename, + status, + errorMessage, + isLoading, + + // Result management functions + resetResults, + clearError, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/convert/useConvertParameters.test.ts b/frontend/src/hooks/tools/convert/useConvertParameters.test.ts new file mode 100644 index 000000000..b106c18cd --- /dev/null +++ b/frontend/src/hooks/tools/convert/useConvertParameters.test.ts @@ -0,0 +1,223 @@ +/** + * Unit tests for useConvertParameters hook + */ + +import { describe, test, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useConvertParameters } from './useConvertParameters'; + +describe('useConvertParameters', () => { + + describe('Parameter Management', () => { + + test('should initialize with default parameters', () => { + const { result } = renderHook(() => useConvertParameters()); + + expect(result.current.parameters.fromExtension).toBe(''); + expect(result.current.parameters.toExtension).toBe(''); + expect(result.current.parameters.imageOptions.colorType).toBe('color'); + expect(result.current.parameters.imageOptions.dpi).toBe(300); + expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('multiple'); + expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.0); + expect(result.current.parameters.emailOptions.includeAttachments).toBe(true); + expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(10); + expect(result.current.parameters.emailOptions.downloadHtml).toBe(false); + expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(false); + expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa-1'); + }); + + test('should update individual parameters', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('fromExtension', 'pdf'); + }); + + expect(result.current.parameters.fromExtension).toBe('pdf'); + expect(result.current.parameters.toExtension).toBe(''); // Should not affect other params + }); + + test('should update nested image options', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('imageOptions', { + colorType: 'grayscale', + dpi: 150, + singleOrMultiple: 'single' + }); + }); + + expect(result.current.parameters.imageOptions.colorType).toBe('grayscale'); + expect(result.current.parameters.imageOptions.dpi).toBe(150); + expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('single'); + }); + + test('should update nested HTML options', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('htmlOptions', { + zoomLevel: 1.5 + }); + }); + + expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.5); + }); + + test('should update nested email options', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('emailOptions', { + includeAttachments: false, + maxAttachmentSizeMB: 20, + downloadHtml: true, + includeAllRecipients: true + }); + }); + + expect(result.current.parameters.emailOptions.includeAttachments).toBe(false); + expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(20); + expect(result.current.parameters.emailOptions.downloadHtml).toBe(true); + expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(true); + }); + + test('should update nested PDF/A options', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('pdfaOptions', { + outputFormat: 'pdfa' + }); + }); + + expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa'); + }); + + test('should reset parameters to defaults', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('fromExtension', 'pdf'); + result.current.updateParameter('toExtension', 'png'); + }); + + expect(result.current.parameters.fromExtension).toBe('pdf'); + + act(() => { + result.current.resetParameters(); + }); + + expect(result.current.parameters.fromExtension).toBe(''); + expect(result.current.parameters.toExtension).toBe(''); + }); + }); + + describe('Parameter Validation', () => { + + test('should validate parameters correctly', () => { + const { result } = renderHook(() => useConvertParameters()); + + // No parameters - should be invalid + expect(result.current.validateParameters()).toBe(false); + + // Only fromExtension - should be invalid + act(() => { + result.current.updateParameter('fromExtension', 'pdf'); + }); + expect(result.current.validateParameters()).toBe(false); + + // Both extensions with supported conversion - should be valid + act(() => { + result.current.updateParameter('toExtension', 'png'); + }); + expect(result.current.validateParameters()).toBe(true); + }); + + test('should validate unsupported conversions', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('fromExtension', 'pdf'); + result.current.updateParameter('toExtension', 'unsupported'); + }); + + expect(result.current.validateParameters()).toBe(false); + }); + + }); + + describe('Endpoint Generation', () => { + + test('should generate correct endpoint names', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('fromExtension', 'pdf'); + result.current.updateParameter('toExtension', 'png'); + }); + + const endpointName = result.current.getEndpointName(); + expect(endpointName).toBe('pdf-to-img'); + }); + + test('should generate correct endpoint URLs', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('fromExtension', 'pdf'); + result.current.updateParameter('toExtension', 'png'); + }); + + const endpoint = result.current.getEndpoint(); + expect(endpoint).toBe('/api/v1/convert/pdf/img'); + }); + + test('should return empty strings for invalid conversions', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('fromExtension', 'invalid'); + result.current.updateParameter('toExtension', 'invalid'); + }); + + expect(result.current.getEndpointName()).toBe(''); + expect(result.current.getEndpoint()).toBe(''); + }); + }); + + describe('Available Extensions', () => { + + test('should return available extensions for valid source format', () => { + const { result } = renderHook(() => useConvertParameters()); + + const availableExtensions = result.current.getAvailableToExtensions('pdf'); + + expect(availableExtensions.length).toBeGreaterThan(0); + expect(availableExtensions.some(ext => ext.value === 'png')).toBe(true); + expect(availableExtensions.some(ext => ext.value === 'jpg')).toBe(true); + }); + + test('should return empty array for invalid source format', () => { + const { result } = renderHook(() => useConvertParameters()); + + const availableExtensions = result.current.getAvailableToExtensions('invalid'); + + expect(availableExtensions).toEqual([{ + "group": "Document", + "label": "PDF", + "value": "pdf", + }]); + }); + + test('should return empty array for empty source format', () => { + const { result } = renderHook(() => useConvertParameters()); + + const availableExtensions = result.current.getAvailableToExtensions(''); + + expect(availableExtensions).toEqual([]); + }); + }); + +}); \ No newline at end of file diff --git a/frontend/src/hooks/tools/convert/useConvertParameters.ts b/frontend/src/hooks/tools/convert/useConvertParameters.ts new file mode 100644 index 000000000..9843ef44f --- /dev/null +++ b/frontend/src/hooks/tools/convert/useConvertParameters.ts @@ -0,0 +1,327 @@ +import { useState, useEffect } from 'react'; +import { + COLOR_TYPES, + OUTPUT_OPTIONS, + FIT_OPTIONS, + TO_FORMAT_OPTIONS, + CONVERSION_MATRIX, + type ColorType, + type OutputOption, + type FitOption +} from '../../../constants/convertConstants'; +import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils'; +import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils'; + +export interface ConvertParameters { + fromExtension: string; + toExtension: string; + imageOptions: { + colorType: ColorType; + dpi: number; + singleOrMultiple: OutputOption; + fitOption: FitOption; + autoRotate: boolean; + combineImages: boolean; + }; + htmlOptions: { + zoomLevel: number; + }; + emailOptions: { + includeAttachments: boolean; + maxAttachmentSizeMB: number; + downloadHtml: boolean; + includeAllRecipients: boolean; + }; + pdfaOptions: { + outputFormat: string; + }; + isSmartDetection: boolean; + smartDetectionType: 'mixed' | 'images' | 'web' | 'none'; +} + +export interface ConvertParametersHook { + parameters: ConvertParameters; + updateParameter: (parameter: keyof ConvertParameters, value: any) => void; + resetParameters: () => void; + validateParameters: () => boolean; + getEndpointName: () => string; + getEndpoint: () => string; + getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; + analyzeFileTypes: (files: Array<{name: string}>) => void; +} + +const initialParameters: ConvertParameters = { + fromExtension: '', + toExtension: '', + imageOptions: { + colorType: COLOR_TYPES.COLOR, + dpi: 300, + singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE, + fitOption: FIT_OPTIONS.MAINTAIN_ASPECT, + autoRotate: true, + combineImages: true, + }, + htmlOptions: { + zoomLevel: 1.0, + }, + emailOptions: { + includeAttachments: true, + maxAttachmentSizeMB: 10, + downloadHtml: false, + includeAllRecipients: false, + }, + pdfaOptions: { + outputFormat: 'pdfa-1', + }, + isSmartDetection: false, + smartDetectionType: 'none', +}; + +export const useConvertParameters = (): ConvertParametersHook => { + const [parameters, setParameters] = useState(initialParameters); + + const updateParameter = (parameter: keyof ConvertParameters, value: any) => { + setParameters(prev => ({ ...prev, [parameter]: value })); + }; + + const resetParameters = () => { + setParameters(initialParameters); + }; + + const validateParameters = () => { + const { fromExtension, toExtension } = parameters; + + if (!fromExtension || !toExtension) return false; + + // Handle dynamic format identifiers (file-) + let supportedToExtensions: string[] = []; + if (fromExtension.startsWith('file-')) { + // Dynamic format - use 'any' conversion options + supportedToExtensions = CONVERSION_MATRIX['any'] || []; + } else { + // Regular format - check conversion matrix + supportedToExtensions = CONVERSION_MATRIX[fromExtension] || []; + } + + if (!supportedToExtensions.includes(toExtension)) { + return false; + } + + return true; + }; + + const getEndpointName = () => { + const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = parameters; + + if (isSmartDetection) { + if (smartDetectionType === 'mixed') { + // Mixed file types -> PDF using file-to-pdf endpoint + return 'file-to-pdf'; + } else if (smartDetectionType === 'images') { + // All images -> PDF using img-to-pdf endpoint + return 'img-to-pdf'; + } else if (smartDetectionType === 'web') { + // All web files -> PDF using html-to-pdf endpoint + return 'html-to-pdf'; + } + } + + // Handle dynamic format identifiers (file-) + if (fromExtension.startsWith('file-')) { + // Dynamic format - use file-to-pdf endpoint + return 'file-to-pdf'; + } + + return getEndpointNameUtil(fromExtension, toExtension); + }; + + const getEndpoint = () => { + const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = parameters; + + if (isSmartDetection) { + if (smartDetectionType === 'mixed') { + // Mixed file types -> PDF using file-to-pdf endpoint + return '/api/v1/convert/file/pdf'; + } else if (smartDetectionType === 'images') { + // All images -> PDF using img-to-pdf endpoint + return '/api/v1/convert/img/pdf'; + } else if (smartDetectionType === 'web') { + // All web files -> PDF using html-to-pdf endpoint + return '/api/v1/convert/html/pdf'; + } + } + + // Handle dynamic format identifiers (file-) + if (fromExtension.startsWith('file-')) { + // Dynamic format - use file-to-pdf endpoint + return '/api/v1/convert/file/pdf'; + } + + return getEndpointUrl(fromExtension, toExtension); + }; + + const getAvailableToExtensions = (fromExtension: string) => { + if (!fromExtension) return []; + + // Handle dynamic format identifiers (file-) + if (fromExtension.startsWith('file-')) { + // Dynamic format - use 'any' conversion options (file-to-pdf) + const supportedExtensions = CONVERSION_MATRIX['any'] || []; + return TO_FORMAT_OPTIONS.filter(option => + supportedExtensions.includes(option.value) + ); + } + + let supportedExtensions = CONVERSION_MATRIX[fromExtension] || []; + + // If no explicit conversion exists, but file-to-pdf might be available, + // fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf) + if (supportedExtensions.length === 0 && fromExtension !== 'any') { + supportedExtensions = CONVERSION_MATRIX['any'] || []; + } + + return TO_FORMAT_OPTIONS.filter(option => + supportedExtensions.includes(option.value) + ); + }; + + + const analyzeFileTypes = (files: Array<{name: string}>) => { + if (files.length === 0) { + // No files - only reset smart detection, keep user's format choices + setParameters(prev => ({ + ...prev, + isSmartDetection: false, + smartDetectionType: 'none' + // Don't reset fromExtension and toExtension - let user keep their choices + })); + return; + } + + if (files.length === 1) { + // Single file - use regular detection with smart target selection + const detectedExt = detectFileExtensionUtil(files[0].name); + let fromExt = detectedExt; + let availableTargets = detectedExt ? CONVERSION_MATRIX[detectedExt] || [] : []; + + // If no explicit conversion exists for this file type, create a dynamic format entry + // and fall back to 'any' conversion logic for the actual endpoint + if (availableTargets.length === 0 && detectedExt) { + fromExt = `file-${detectedExt}`; // Create dynamic format identifier + availableTargets = CONVERSION_MATRIX['any'] || []; + } else if (availableTargets.length === 0) { + // No extension detected - fall back to 'any' + fromExt = 'any'; + availableTargets = CONVERSION_MATRIX['any'] || []; + } + + setParameters(prev => { + // Check if current toExtension is still valid for the new fromExtension + const currentToExt = prev.toExtension; + const isCurrentToExtValid = availableTargets.includes(currentToExt); + + // Auto-select target only if: + // 1. No current target is set, OR + // 2. Current target is invalid for new source type, OR + // 3. There's only one possible target (forced conversion) + let newToExtension = currentToExt; + if (!currentToExt || !isCurrentToExtValid) { + newToExtension = availableTargets.length === 1 ? availableTargets[0] : ''; + } + + return { + ...prev, + isSmartDetection: false, + smartDetectionType: 'none', + fromExtension: fromExt, + toExtension: newToExtension + }; + }); + return; + } + + // Multiple files - analyze file types + const extensions = files.map(file => detectFileExtensionUtil(file.name)); + const uniqueExtensions = [...new Set(extensions)]; + + if (uniqueExtensions.length === 1) { + // All files are the same type - use regular detection with smart target selection + const detectedExt = uniqueExtensions[0]; + let fromExt = detectedExt; + let availableTargets = CONVERSION_MATRIX[detectedExt] || []; + + // If no explicit conversion exists for this file type, fall back to 'any' + if (availableTargets.length === 0) { + fromExt = 'any'; + availableTargets = CONVERSION_MATRIX['any'] || []; + } + + setParameters(prev => { + // Check if current toExtension is still valid for the new fromExtension + const currentToExt = prev.toExtension; + const isCurrentToExtValid = availableTargets.includes(currentToExt); + + // Auto-select target only if: + // 1. No current target is set, OR + // 2. Current target is invalid for new source type, OR + // 3. There's only one possible target (forced conversion) + let newToExtension = currentToExt; + if (!currentToExt || !isCurrentToExtValid) { + newToExtension = availableTargets.length === 1 ? availableTargets[0] : ''; + } + + return { + ...prev, + isSmartDetection: false, + smartDetectionType: 'none', + fromExtension: fromExt, + toExtension: newToExtension + }; + }); + } else { + // Mixed file types + const allImages = uniqueExtensions.every(ext => isImageFormat(ext)); + const allWeb = uniqueExtensions.every(ext => isWebFormat(ext)); + + if (allImages) { + // All files are images - use image-to-pdf conversion + setParameters(prev => ({ + ...prev, + isSmartDetection: true, + smartDetectionType: 'images', + fromExtension: 'image', + toExtension: 'pdf' + })); + } else if (allWeb) { + // All files are web files - use html-to-pdf conversion + setParameters(prev => ({ + ...prev, + isSmartDetection: true, + smartDetectionType: 'web', + fromExtension: 'html', + toExtension: 'pdf' + })); + } else { + // Mixed non-image types - use file-to-pdf conversion + setParameters(prev => ({ + ...prev, + isSmartDetection: true, + smartDetectionType: 'mixed', + fromExtension: 'any', + toExtension: 'pdf' + })); + } + } + }; + + return { + parameters, + updateParameter, + resetParameters, + validateParameters, + getEndpointName, + getEndpoint, + getAvailableToExtensions, + analyzeFileTypes, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts new file mode 100644 index 000000000..4552a4546 --- /dev/null +++ b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts @@ -0,0 +1,365 @@ +/** + * Tests for auto-detection and smart conversion features in useConvertParameters + * This covers the analyzeFileTypes function and related smart detection logic + */ + +import { describe, test, expect } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useConvertParameters } from './useConvertParameters'; + +describe('useConvertParameters - Auto Detection & Smart Conversion', () => { + + describe('Single File Detection', () => { + + test('should detect single file extension and set auto-target', () => { + const { result } = renderHook(() => useConvertParameters()); + + const pdfFile = [{ name: 'document.pdf' }]; + + act(() => { + result.current.analyzeFileTypes(pdfFile); + }); + + expect(result.current.parameters.fromExtension).toBe('pdf'); + expect(result.current.parameters.toExtension).toBe(''); // No auto-selection for multiple targets + expect(result.current.parameters.isSmartDetection).toBe(false); + expect(result.current.parameters.smartDetectionType).toBe('none'); + }); + + test('should handle unknown file types with file-to-pdf fallback', () => { + const { result } = renderHook(() => useConvertParameters()); + + const unknownFile = [{ name: 'document.xyz' }, { name: 'image.jpggg' }]; + + act(() => { + result.current.analyzeFileTypes(unknownFile); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf + expect(result.current.parameters.isSmartDetection).toBe(true); + }); + + test('should handle files without extensions', () => { + const { result } = renderHook(() => useConvertParameters()); + + const noExtFile = [{ name: 'document' }]; + + act(() => { + result.current.analyzeFileTypes(noExtFile); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf + }); + + + }); + + describe('Multiple Identical Files', () => { + + test('should detect multiple PDF files and set auto-target', () => { + const { result } = renderHook(() => useConvertParameters()); + + const pdfFiles = [ + { name: 'doc1.pdf' }, + { name: 'doc2.pdf' }, + { name: 'doc3.pdf' } + ]; + + act(() => { + result.current.analyzeFileTypes(pdfFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('pdf'); + expect(result.current.parameters.toExtension).toBe(''); // Auto-selected + expect(result.current.parameters.isSmartDetection).toBe(false); + expect(result.current.parameters.smartDetectionType).toBe('none'); + }); + + test('should handle multiple unknown file types with fallback', () => { + const { result } = renderHook(() => useConvertParameters()); + + const unknownFiles = [ + { name: 'file1.xyz' }, + { name: 'file2.xyz' } + ]; + + act(() => { + result.current.analyzeFileTypes(unknownFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(false); + }); + }); + + describe('Smart Detection - All Images', () => { + + test('should detect all image files and enable smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const imageFiles = [ + { name: 'photo1.jpg' }, + { name: 'photo2.png' }, + { name: 'photo3.gif' } + ]; + + act(() => { + result.current.analyzeFileTypes(imageFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('image'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('images'); + }); + + test('should handle mixed case image extensions', () => { + const { result } = renderHook(() => useConvertParameters()); + + const imageFiles = [ + { name: 'photo1.JPG' }, + { name: 'photo2.PNG' } + ]; + + act(() => { + result.current.analyzeFileTypes(imageFiles); + }); + + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('images'); + }); + }); + + describe('Smart Detection - All Web Files', () => { + + test('should detect all web files and enable web smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const webFiles = [ + { name: 'page1.html' }, + { name: 'archive.zip' } + ]; + + act(() => { + result.current.analyzeFileTypes(webFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('html'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('web'); + }); + + test('should handle mixed case web extensions', () => { + const { result } = renderHook(() => useConvertParameters()); + + const webFiles = [ + { name: 'page1.HTML' }, + { name: 'archive.ZIP' } + ]; + + act(() => { + result.current.analyzeFileTypes(webFiles); + }); + + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('web'); + }); + + test('should detect multiple web files and enable web smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const zipFiles = [ + { name: 'site1.zip' }, + { name: 'site2.html' } + ]; + + act(() => { + result.current.analyzeFileTypes(zipFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('html'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('web'); + }); + }); + + describe('Smart Detection - Mixed File Types', () => { + + test('should detect mixed file types and enable smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const mixedFiles = [ + { name: 'document.pdf' }, + { name: 'spreadsheet.xlsx' }, + { name: 'presentation.pptx' } + ]; + + act(() => { + result.current.analyzeFileTypes(mixedFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('mixed'); + }); + + test('should detect mixed images and documents as mixed type', () => { + const { result } = renderHook(() => useConvertParameters()); + + const mixedFiles = [ + { name: 'photo.jpg' }, + { name: 'document.pdf' }, + { name: 'text.txt' } + ]; + + act(() => { + result.current.analyzeFileTypes(mixedFiles); + }); + + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('mixed'); + }); + + test('should handle mixed with unknown file types', () => { + const { result } = renderHook(() => useConvertParameters()); + + const mixedFiles = [ + { name: 'document.pdf' }, + { name: 'unknown.xyz' }, + { name: 'noextension' } + ]; + + act(() => { + result.current.analyzeFileTypes(mixedFiles); + }); + + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('mixed'); + }); + }); + + describe('Smart Detection Endpoint Resolution', () => { + + test('should return correct endpoint for image smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const imageFiles = [ + { name: 'photo1.jpg' }, + { name: 'photo2.png' } + ]; + + act(() => { + result.current.analyzeFileTypes(imageFiles); + }); + + expect(result.current.getEndpointName()).toBe('img-to-pdf'); + expect(result.current.getEndpoint()).toBe('/api/v1/convert/img/pdf'); + }); + + test('should return correct endpoint for web smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const webFiles = [ + { name: 'page1.html' }, + { name: 'archive.zip' } + ]; + + act(() => { + result.current.analyzeFileTypes(webFiles); + }); + + expect(result.current.getEndpointName()).toBe('html-to-pdf'); + expect(result.current.getEndpoint()).toBe('/api/v1/convert/html/pdf'); + }); + + test('should return correct endpoint for mixed smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const mixedFiles = [ + { name: 'document.pdf' }, + { name: 'spreadsheet.xlsx' } + ]; + + act(() => { + result.current.analyzeFileTypes(mixedFiles); + }); + + expect(result.current.getEndpointName()).toBe('file-to-pdf'); + expect(result.current.getEndpoint()).toBe('/api/v1/convert/file/pdf'); + }); + }); + + describe('Auto-Target Selection Logic', () => { + + test('should select single available target automatically', () => { + const { result } = renderHook(() => useConvertParameters()); + + // Markdown has only one conversion target (PDF) + const mdFile = [{ name: 'readme.md' }]; + + act(() => { + result.current.analyzeFileTypes(mdFile); + }); + + expect(result.current.parameters.fromExtension).toBe('md'); + expect(result.current.parameters.toExtension).toBe('pdf'); // Only available target + }); + + test('should not auto-select when multiple targets available', () => { + const { result } = renderHook(() => useConvertParameters()); + + // PDF has multiple conversion targets, so no auto-selection + const pdfFile = [{ name: 'document.pdf' }]; + + act(() => { + result.current.analyzeFileTypes(pdfFile); + }); + + expect(result.current.parameters.fromExtension).toBe('pdf'); + // Should NOT auto-select when multiple targets available + expect(result.current.parameters.toExtension).toBe(''); + }); + }); + + describe('Edge Cases', () => { + + test('should handle empty file names', () => { + const { result } = renderHook(() => useConvertParameters()); + + const emptyFiles = [{ name: '' }]; + + act(() => { + result.current.analyzeFileTypes(emptyFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); + }); + + test('should handle malformed file objects', () => { + const { result } = renderHook(() => useConvertParameters()); + + const malformedFiles = [ + { name: 'valid.pdf' }, + // @ts-ignore - Testing runtime resilience + { name: null }, + // @ts-ignore + { name: undefined } + ]; + + act(() => { + result.current.analyzeFileTypes(malformedFiles); + }); + + // Should still process the valid file and handle gracefully + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('mixed'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/hooks/usePdfSignatureDetection.ts b/frontend/src/hooks/usePdfSignatureDetection.ts new file mode 100644 index 000000000..96d80fb9d --- /dev/null +++ b/frontend/src/hooks/usePdfSignatureDetection.ts @@ -0,0 +1,66 @@ +import { useState, useEffect } from 'react'; +import * as pdfjsLib from 'pdfjs-dist'; + +export interface PdfSignatureDetectionResult { + hasDigitalSignatures: boolean; + isChecking: boolean; +} + +export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => { + const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false); + const [isChecking, setIsChecking] = useState(false); + + useEffect(() => { + const checkForDigitalSignatures = async () => { + if (files.length === 0) { + setHasDigitalSignatures(false); + return; + } + + setIsChecking(true); + let foundSignature = false; + + try { + // Set up PDF.js worker + pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-legacy/pdf.worker.mjs'; + + for (const file of files) { + const arrayBuffer = await file.arrayBuffer(); + + try { + const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; + + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const annotations = await page.getAnnotations({ intent: 'display' }); + + annotations.forEach(annotation => { + if (annotation.subtype === 'Widget' && annotation.fieldType === 'Sig') { + foundSignature = true; + } + }); + + if (foundSignature) break; + } + } catch (error) { + console.warn('Error analyzing PDF for signatures:', error); + } + + if (foundSignature) break; + } + } catch (error) { + console.warn('Error checking for digital signatures:', error); + } + + setHasDigitalSignatures(foundSignature); + setIsChecking(false); + }; + + checkForDigitalSignatures(); + }, [files]); + + return { + hasDigitalSignatures, + isChecking + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 80cbd57f4..debd3f5b1 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -2,6 +2,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import ContentCutIcon from "@mui/icons-material/ContentCut"; import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; +import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; import ApiIcon from "@mui/icons-material/Api"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool"; @@ -27,6 +28,33 @@ const toolDefinitions: Record = { description: "Reduce PDF file size", endpoints: ["compress-pdf"] }, + convert: { + id: "convert", + icon: , + component: React.lazy(() => import("../tools/Convert")), + maxFiles: -1, + category: "manipulation", + description: "Change to and from PDF and other formats", + endpoints: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-html", "pdf-to-xml", "html-to-pdf", "markdown-to-pdf", "file-to-pdf"], + supportedFormats: [ + // Microsoft Office + "doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx", + // OpenDocument + "odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg", + // Text formats + "txt", "text", "xml", "rtf", "html", "lwp", "md", + // Images + "bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp", + // StarOffice + "sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw", + // Email formats + "eml", + // Archive formats + "zip", + // Other + "dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf" + ] + }, swagger: { id: "swagger", icon: , @@ -50,7 +78,6 @@ const toolDefinitions: Record = { }; - interface ToolManagementResult { selectedToolKey: string | null; selectedTool: Tool | null; diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts new file mode 100644 index 000000000..2a7e6a431 --- /dev/null +++ b/frontend/src/i18n/config.ts @@ -0,0 +1,48 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import Backend from 'i18next-http-backend'; + +i18n + .use(Backend) + .use(initReactI18next) + .init({ + lng: 'en', + fallbackLng: 'en', + debug: false, + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json', + }, + interpolation: { + escapeValue: false, + }, + // For testing environment, provide fallback resources + resources: { + en: { + translation: { + 'convert.selectSourceFormat': 'Select source file format', + 'convert.selectTargetFormat': 'Select target file format', + 'convert.selectFirst': 'Select a source format first', + 'convert.imageOptions': 'Image Options:', + 'convert.emailOptions': 'Email Options:', + 'convert.colorType': 'Color Type', + 'convert.dpi': 'DPI', + 'convert.singleOrMultiple': 'Output', + 'convert.emailNote': 'Email attachments and embedded images will be included', + 'common.color': 'Color', + 'common.grayscale': 'Grayscale', + 'common.blackWhite': 'Black & White', + 'common.single': 'Single Image', + 'common.multiple': 'Multiple Images', + 'groups.document': 'Document', + 'groups.spreadsheet': 'Spreadsheet', + 'groups.presentation': 'Presentation', + 'groups.image': 'Image', + 'groups.web': 'Web', + 'groups.text': 'Text', + 'groups.email': 'Email' + } + } + } + }); + +export default i18n; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d24c58b44..cde8d3320 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -196,7 +196,8 @@ function HomePageContent() { onFilesSelect={(files) => { files.forEach(addToActiveFiles); }} - accept={["application/pdf"]} + accept={["*/*"]} + supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} loading={false} showRecentFiles={true} maxRecentFiles={8} @@ -207,6 +208,7 @@ function HomePageContent() { toolMode={!!selectedToolKey} showUpload={true} showBulkActions={!selectedToolKey} + supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} {...(!selectedToolKey && { onOpenPageEditor: (file) => { handleViewChange("pageEditor"); @@ -236,6 +238,11 @@ function HomePageContent() { setCurrentView('compress'); setLeftPanelView('toolContent'); sessionStorage.removeItem('previousMode'); + } else if (previousMode === 'convert') { + selectTool('convert'); + setCurrentView('convert'); + setLeftPanelView('toolContent'); + sessionStorage.removeItem('previousMode'); } else { setCurrentView('fileEditor'); } @@ -281,7 +288,8 @@ function HomePageContent() { onFilesSelect={(files) => { files.forEach(addToActiveFiles); }} - accept={["application/pdf"]} + accept={["*/*"]} + supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} loading={false} showRecentFiles={true} maxRecentFiles={8} diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts new file mode 100644 index 000000000..0f5ca8648 --- /dev/null +++ b/frontend/src/setupTests.ts @@ -0,0 +1,123 @@ +import '@testing-library/jest-dom' +import { vi } from 'vitest' + +// Mock i18next for tests +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + changeLanguage: vi.fn(), + }, + }), + initReactI18next: { + type: '3rdParty', + init: vi.fn(), + }, + I18nextProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +// Mock i18next-http-backend +vi.mock('i18next-http-backend', () => ({ + default: { + type: 'backend', + init: vi.fn(), + read: vi.fn(), + save: vi.fn(), + }, +})); + +// Mock window.URL.createObjectURL and revokeObjectURL for tests +global.URL.createObjectURL = vi.fn(() => 'mocked-url') +global.URL.revokeObjectURL = vi.fn() + +// Mock File and Blob API methods that aren't available in jsdom +if (!globalThis.File.prototype.arrayBuffer) { + globalThis.File.prototype.arrayBuffer = function() { + // Return a simple ArrayBuffer with some mock data + const buffer = new ArrayBuffer(8); + const view = new Uint8Array(buffer); + view.set([1, 2, 3, 4, 5, 6, 7, 8]); + return Promise.resolve(buffer); + }; +} + +if (!globalThis.Blob.prototype.arrayBuffer) { + globalThis.Blob.prototype.arrayBuffer = function() { + // Return a simple ArrayBuffer with some mock data + const buffer = new ArrayBuffer(8); + const view = new Uint8Array(buffer); + view.set([1, 2, 3, 4, 5, 6, 7, 8]); + return Promise.resolve(buffer); + }; +} + +// Mock crypto.subtle for hashing in tests - force override even if exists +const mockHashBuffer = new ArrayBuffer(32); +const mockHashView = new Uint8Array(mockHashBuffer); +// Fill with predictable mock hash data +for (let i = 0; i < 32; i++) { + mockHashView[i] = i; +} + +// Force override crypto.subtle to avoid Node.js native implementation +Object.defineProperty(globalThis, 'crypto', { + value: { + subtle: { + digest: vi.fn().mockImplementation(async (algorithm: string, data: any) => { + // Always return the mock hash buffer regardless of input + return mockHashBuffer.slice(); + }), + }, + getRandomValues: vi.fn().mockImplementation((array: any) => { + // Mock getRandomValues if needed + for (let i = 0; i < array.length; i++) { + array[i] = Math.floor(Math.random() * 256); + } + return array; + }), + } as Crypto, + writable: true, + configurable: true, +}); + +// Mock Worker for tests (Web Workers not available in test environment) +global.Worker = vi.fn().mockImplementation(() => ({ + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + onmessage: null, + onerror: null, +})) + +// Mock ResizeObserver for Mantine components +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) + +// Mock IntersectionObserver for components that might use it +global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) + +// Mock matchMedia for responsive components +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +// Set global test timeout to prevent hangs +vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 }) \ No newline at end of file diff --git a/frontend/src/tests/convert/ConvertE2E.spec.ts b/frontend/src/tests/convert/ConvertE2E.spec.ts new file mode 100644 index 000000000..e60f7826c --- /dev/null +++ b/frontend/src/tests/convert/ConvertE2E.spec.ts @@ -0,0 +1,429 @@ +/** + * End-to-End Tests for Convert Tool + * + * These tests dynamically discover available conversion endpoints and test them. + * Tests are automatically skipped if the backend endpoint is not available. + * + * Run with: npm run test:e2e or npx playwright test + */ + +import { test, expect, Page } from '@playwright/test'; +import { + conversionDiscovery, + type ConversionEndpoint +} from '../helpers/conversionEndpointDiscovery'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Test configuration +const BASE_URL = process.env.BASE_URL || 'http://localhost:5173'; +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8080'; + +/** + * Resolves test fixture paths dynamically based on current working directory. + * Works from both top-level project directory and frontend subdirectory. + */ +function resolveTestFixturePath(filename: string): string { + const cwd = process.cwd(); + + // Try frontend/src/tests/test-fixtures/ first (from top-level) + const topLevelPath = path.join(cwd, 'frontend', 'src', 'tests', 'test-fixtures', filename); + if (fs.existsSync(topLevelPath)) { + return topLevelPath; + } + + // Try src/tests/test-fixtures/ (from frontend directory) + const frontendPath = path.join(cwd, 'src', 'tests', 'test-fixtures', filename); + if (fs.existsSync(frontendPath)) { + return frontendPath; + } + + // Try relative path from current test file location + const relativePath = path.join(__dirname, '..', 'test-fixtures', filename); + if (fs.existsSync(relativePath)) { + return relativePath; + } + + // Fallback to the original path format (should work from top-level) + return path.join('.', 'frontend', 'src', 'tests', 'test-fixtures', filename); +} + +// Test file paths (dynamically resolved based on current working directory) +const TEST_FILES = { + pdf: resolveTestFixturePath('sample.pdf'), + docx: resolveTestFixturePath('sample.docx'), + doc: resolveTestFixturePath('sample.doc'), + pptx: resolveTestFixturePath('sample.pptx'), + ppt: resolveTestFixturePath('sample.ppt'), + xlsx: resolveTestFixturePath('sample.xlsx'), + xls: resolveTestFixturePath('sample.xls'), + png: resolveTestFixturePath('sample.png'), + jpg: resolveTestFixturePath('sample.jpg'), + jpeg: resolveTestFixturePath('sample.jpeg'), + gif: resolveTestFixturePath('sample.gif'), + bmp: resolveTestFixturePath('sample.bmp'), + tiff: resolveTestFixturePath('sample.tiff'), + webp: resolveTestFixturePath('sample.webp'), + md: resolveTestFixturePath('sample.md'), + eml: resolveTestFixturePath('sample.eml'), + html: resolveTestFixturePath('sample.html'), + txt: resolveTestFixturePath('sample.txt'), + xml: resolveTestFixturePath('sample.xml'), + csv: resolveTestFixturePath('sample.csv') +}; + +// File format to test file mapping +const getTestFileForFormat = (format: string): string => { + const formatMap: Record = { + 'pdf': TEST_FILES.pdf, + 'docx': TEST_FILES.docx, + 'doc': TEST_FILES.doc, + 'pptx': TEST_FILES.pptx, + 'ppt': TEST_FILES.ppt, + 'xlsx': TEST_FILES.xlsx, + 'xls': TEST_FILES.xls, + 'office': TEST_FILES.docx, // Default office file + 'image': TEST_FILES.png, // Default image file + 'png': TEST_FILES.png, + 'jpg': TEST_FILES.jpg, + 'jpeg': TEST_FILES.jpeg, + 'gif': TEST_FILES.gif, + 'bmp': TEST_FILES.bmp, + 'tiff': TEST_FILES.tiff, + 'webp': TEST_FILES.webp, + 'md': TEST_FILES.md, + 'eml': TEST_FILES.eml, + 'html': TEST_FILES.html, + 'txt': TEST_FILES.txt, + 'xml': TEST_FILES.xml, + 'csv': TEST_FILES.csv + }; + + return formatMap[format] || TEST_FILES.pdf; // Fallback to PDF +}; + +// Expected file extensions for target formats +const getExpectedExtension = (toFormat: string): string => { + const extensionMap: Record = { + 'pdf': '.pdf', + 'docx': '.docx', + 'pptx': '.pptx', + 'txt': '.txt', + 'html': '.zip', // HTML is zipped + 'xml': '.xml', + 'csv': '.csv', + 'md': '.md', + 'image': '.png', // Default for image conversion + 'png': '.png', + 'jpg': '.jpg', + 'jpeg': '.jpeg', + 'gif': '.gif', + 'bmp': '.bmp', + 'tiff': '.tiff', + 'webp': '.webp', + 'pdfa': '.pdf' + }; + + return extensionMap[toFormat] || '.pdf'; +}; + +/** + * Generic test function for any conversion + */ +async function testConversion(page: Page, conversion: ConversionEndpoint) { + const expectedExtension = getExpectedExtension(conversion.toFormat); + + console.log(`Testing ${conversion.endpoint}: ${conversion.fromFormat} → ${conversion.toFormat}`); + + // File should already be uploaded, click the Convert tool button + await page.click('[data-testid="tool-convert"]'); + + // Wait for the FileEditor to load in convert mode with file thumbnails + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 5000 }); + + // Click the file thumbnail checkbox to select it in the FileEditor + await page.click('[data-testid="file-thumbnail-checkbox"]'); + + // Wait for the conversion settings to appear after file selection + await page.waitForSelector('[data-testid="convert-from-dropdown"]', { timeout: 5000 }); + + // Select FROM format + await page.click('[data-testid="convert-from-dropdown"]'); + const fromFormatOption = page.locator(`[data-testid="format-option-${conversion.fromFormat}"]`); + await fromFormatOption.scrollIntoViewIfNeeded(); + await fromFormatOption.click(); + + // Select TO format + await page.click('[data-testid="convert-to-dropdown"]'); + const toFormatOption = page.locator(`[data-testid="format-option-${conversion.toFormat}"]`); + await toFormatOption.scrollIntoViewIfNeeded(); + await toFormatOption.click(); + + // Handle format-specific options + if (conversion.toFormat === 'image' || ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'webp'].includes(conversion.toFormat)) { + // Set image conversion options if they appear + const imageOptionsVisible = await page.locator('[data-testid="image-options-section"]').isVisible().catch(() => false); + if (imageOptionsVisible) { + // Click the color type dropdown and select "Color" + await page.click('[data-testid="color-type-select"]'); + await page.getByRole('option', { name: 'Color' }).click(); + + // Set DPI value + await page.fill('[data-testid="dpi-input"]', '150'); + + // Click the output type dropdown and select "Multiple" + await page.click('[data-testid="output-type-select"]'); + + await page.getByRole('option', { name: 'single' }).click(); + } + } + + if (conversion.fromFormat === 'image' && conversion.toFormat === 'pdf') { + // Set PDF creation options if they appear + const pdfOptionsVisible = await page.locator('[data-testid="pdf-options-section"]').isVisible().catch(() => false); + if (pdfOptionsVisible) { + // Click the color type dropdown and select "Color" + await page.click('[data-testid="color-type-select"]'); + await page.locator('[data-value="color"]').click(); + } + } + + if (conversion.fromFormat === 'pdf' && conversion.toFormat === 'csv') { + // Set CSV extraction options if they appear + const csvOptionsVisible = await page.locator('[data-testid="csv-options-section"]').isVisible().catch(() => false); + if (csvOptionsVisible) { + // Set specific page numbers for testing (test pages 1-2) + await page.fill('[data-testid="page-numbers-input"]', '1-2'); + } + } + + // Start conversion + await page.click('[data-testid="convert-button"]'); + + // Wait for conversion to complete (with generous timeout) + await page.waitForSelector('[data-testid="download-button"]', { timeout: 60000 }); + + // Verify download is available + const downloadButton = page.locator('[data-testid="download-button"]'); + await expect(downloadButton).toBeVisible(); + + // Start download and verify file + const downloadPromise = page.waitForEvent('download'); + await downloadButton.click(); + const download = await downloadPromise; + + // Verify file extension + expect(download.suggestedFilename()).toMatch(new RegExp(`\\${expectedExtension}$`)); + + // Save and verify file is not empty + const path = await download.path(); + if (path) { + const fs = require('fs'); + const stats = fs.statSync(path); + expect(stats.size).toBeGreaterThan(0); + + // Format-specific validations + if (conversion.toFormat === 'pdf' || conversion.toFormat === 'pdfa') { + // Verify PDF header + const buffer = fs.readFileSync(path); + const header = buffer.toString('utf8', 0, 4); + expect(header).toBe('%PDF'); + } + + if (conversion.toFormat === 'txt') { + // Verify text content exists + const content = fs.readFileSync(path, 'utf8'); + expect(content.length).toBeGreaterThan(0); + } + + if (conversion.toFormat === 'csv') { + // Verify CSV content contains separators + const content = fs.readFileSync(path, 'utf8'); + expect(content).toContain(','); + } + } +} + +// Discover conversions at module level before tests are defined +let allConversions: ConversionEndpoint[] = []; +let availableConversions: ConversionEndpoint[] = []; +let unavailableConversions: ConversionEndpoint[] = []; + +// Pre-populate conversions synchronously for test generation +(async () => { + try { + availableConversions = await conversionDiscovery.getAvailableConversions(); + unavailableConversions = await conversionDiscovery.getUnavailableConversions(); + allConversions = [...availableConversions, ...unavailableConversions]; + } catch (error) { + console.error('Failed to discover conversions during module load:', error); + } +})(); + +test.describe('Convert Tool E2E Tests', () => { + + test.beforeAll(async () => { + // Re-discover to ensure fresh data at test time + console.log('Re-discovering available conversion endpoints...'); + availableConversions = await conversionDiscovery.getAvailableConversions(); + unavailableConversions = await conversionDiscovery.getUnavailableConversions(); + + console.log(`Found ${availableConversions.length} available conversions:`); + availableConversions.forEach(conv => { + console.log(` ✓ ${conv.endpoint}: ${conv.fromFormat} → ${conv.toFormat}`); + }); + + if (unavailableConversions.length > 0) { + console.log(`Found ${unavailableConversions.length} unavailable conversions:`); + unavailableConversions.forEach(conv => { + console.log(` ✗ ${conv.endpoint}: ${conv.fromFormat} → ${conv.toFormat}`); + }); + } + }); + + test.beforeEach(async ({ page }) => { + // Navigate to the homepage + await page.goto(`${BASE_URL}`); + + // Wait for the page to load + await page.waitForLoadState('networkidle'); + + // Wait for the file upload area to appear (shown when no active files) + await page.waitForSelector('[data-testid="file-dropzone"]', { timeout: 10000 }); + }); + + test.describe('Dynamic Conversion Tests', () => { + + // Generate a test for each potentially available conversion + // We'll discover all possible conversions and then skip unavailable ones at runtime + test('PDF to PNG conversion', async ({ page }) => { + const conversion = { endpoint: '/api/v1/convert/pdf/img', fromFormat: 'pdf', toFormat: 'png' }; + const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); + test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); + + const testFile = getTestFileForFormat(conversion.fromFormat); + await page.setInputFiles('input[type="file"]', testFile); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + await testConversion(page, conversion); + }); + + test('PDF to DOCX conversion', async ({ page }) => { + const conversion = { endpoint: '/api/v1/convert/pdf/word', fromFormat: 'pdf', toFormat: 'docx' }; + const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); + test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); + + const testFile = getTestFileForFormat(conversion.fromFormat); + await page.setInputFiles('input[type="file"]', testFile); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + await testConversion(page, conversion); + }); + + test('DOCX to PDF conversion', async ({ page }) => { + const conversion = { endpoint: '/api/v1/convert/file/pdf', fromFormat: 'docx', toFormat: 'pdf' }; + const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); + test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); + + const testFile = getTestFileForFormat(conversion.fromFormat); + await page.setInputFiles('input[type="file"]', testFile); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + await testConversion(page, conversion); + }); + + test('Image to PDF conversion', async ({ page }) => { + const conversion = { endpoint: '/api/v1/convert/img/pdf', fromFormat: 'png', toFormat: 'pdf' }; + const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); + test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); + + const testFile = getTestFileForFormat(conversion.fromFormat); + await page.setInputFiles('input[type="file"]', testFile); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + await testConversion(page, conversion); + }); + + test('PDF to TXT conversion', async ({ page }) => { + const conversion = { endpoint: '/api/v1/convert/pdf/text', fromFormat: 'pdf', toFormat: 'txt' }; + const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); + test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); + + const testFile = getTestFileForFormat(conversion.fromFormat); + await page.setInputFiles('input[type="file"]', testFile); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + await testConversion(page, conversion); + }); + + test('PDF to HTML conversion', async ({ page }) => { + const conversion = { endpoint: '/api/v1/convert/pdf/html', fromFormat: 'pdf', toFormat: 'html' }; + const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); + test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); + + const testFile = getTestFileForFormat(conversion.fromFormat); + await page.setInputFiles('input[type="file"]', testFile); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + await testConversion(page, conversion); + }); + + test('PDF to XML conversion', async ({ page }) => { + const conversion = { endpoint: '/api/v1/convert/pdf/xml', fromFormat: 'pdf', toFormat: 'xml' }; + const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); + test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); + + const testFile = getTestFileForFormat(conversion.fromFormat); + await page.setInputFiles('input[type="file"]', testFile); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + await testConversion(page, conversion); + }); + + test('PDF to CSV conversion', async ({ page }) => { + const conversion = { endpoint: '/api/v1/convert/pdf/csv', fromFormat: 'pdf', toFormat: 'csv' }; + const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); + test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); + + const testFile = getTestFileForFormat(conversion.fromFormat); + await page.setInputFiles('input[type="file"]', testFile); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + await testConversion(page, conversion); + }); + + test('PDF to PDFA conversion', async ({ page }) => { + const conversion = { endpoint: '/api/v1/convert/pdf/pdfa', fromFormat: 'pdf', toFormat: 'pdfa' }; + const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); + test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); + + const testFile = getTestFileForFormat(conversion.fromFormat); + await page.setInputFiles('input[type="file"]', testFile); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + await testConversion(page, conversion); + }); + }); + + test.describe('Static Tests', () => { + + // Test that disabled conversions don't appear in dropdowns when they shouldn't + test('should not show conversion button when no valid conversions available', async ({ page }) => { + // This test ensures the convert button is disabled when no valid conversion is possible + await page.setInputFiles('input[type="file"]', TEST_FILES.pdf); + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + + // Click the Convert tool button + await page.click('[data-testid="tool-convert"]'); + + // Wait for convert mode and select file + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 5000 }); + await page.click('[data-testid="file-thumbnail-checkbox"]'); + + // Don't select any formats - convert button should not exist + const convertButton = page.locator('[data-testid="convert-button"]'); + await expect(convertButton).toHaveCount(0); + }); + }); +}); + diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx new file mode 100644 index 000000000..c9a636035 --- /dev/null +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -0,0 +1,581 @@ +/** + * Integration tests for Convert Tool - Tests actual conversion functionality + * + * These tests verify the integration between frontend components and backend: + * 1. useConvertOperation hook makes correct API calls + * 2. File upload/download flow functions properly + * 3. Error handling works for various failure scenarios + * 4. Parameter passing works between frontend and backend + * 5. FileContext integration works correctly + */ + +import React from 'react'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation'; +import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameters'; +import { FileContextProvider } from '../../contexts/FileContext'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../i18n/config'; +import axios from 'axios'; + +// Mock axios +vi.mock('axios'); +const mockedAxios = vi.mocked(axios); + +// Mock utility modules +vi.mock('../../utils/thumbnailUtils', () => ({ + generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail') +})); + +vi.mock('../../utils/api', () => ({ + makeApiUrl: vi.fn((path: string) => `/api/v1${path}`) +})); + +// Create realistic test files +const createTestFile = (name: string, content: string, type: string): File => { + return new File([content], name, { type }); +}; + +const createPDFFile = (): File => { + const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF'; + return createTestFile('test.pdf', pdfContent, 'application/pdf'); +}; + +// Test wrapper component +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + + {children} + + +); + +describe('Convert Tool Integration Tests', () => { + + beforeEach(() => { + vi.clearAllMocks(); + // Setup default axios mock + mockedAxios.post = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('useConvertOperation Integration', () => { + + test('should make correct API call for PDF to PNG conversion', async () => { + const mockBlob = new Blob(['fake-image-data'], { type: 'image/png' }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + status: 200, + statusText: 'OK' + }); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const testFile = createPDFFile(); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'png', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + // Verify axios was called with correct parameters + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/v1/convert/pdf/img', + expect.any(FormData), + { responseType: 'blob' } + ); + + // Verify FormData contains correct parameters + const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formDataCall.get('imageFormat')).toBe('png'); + expect(formDataCall.get('colorType')).toBe('color'); + expect(formDataCall.get('dpi')).toBe('300'); + expect(formDataCall.get('singleOrMultiple')).toBe('multiple'); + + // Verify hook state updates + expect(result.current.downloadUrl).toBeTruthy(); + expect(result.current.downloadFilename).toBe('test_converted.png'); + expect(result.current.isLoading).toBe(false); + expect(result.current.errorMessage).toBe(null); + }); + + test('should handle API error responses correctly', async () => { + const errorMessage = 'Invalid file format'; + mockedAxios.post.mockRejectedValueOnce({ + response: { + status: 400, + data: errorMessage + }, + message: errorMessage + }); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const testFile = createTestFile('invalid.txt', 'not a pdf', 'text/plain'); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'png', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + // Verify error handling + expect(result.current.errorMessage).toBe(errorMessage); + expect(result.current.isLoading).toBe(false); + expect(result.current.downloadUrl).toBe(null); + }); + + test('should handle network errors gracefully', async () => { + mockedAxios.post.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const testFile = createPDFFile(); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'png', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + expect(result.current.errorMessage).toBe('Network error'); + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('API and Hook Integration', () => { + + test('should correctly map image conversion parameters to API call', async () => { + const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' }); + mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const testFile = createPDFFile(); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'jpg', + pageNumbers: 'all', + imageOptions: { + colorType: 'grayscale', + dpi: 150, + singleOrMultiple: 'single', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + // Verify integration: hook parameters → FormData → axios call → hook state + const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formDataCall.get('imageFormat')).toBe('jpg'); + expect(formDataCall.get('colorType')).toBe('grayscale'); + expect(formDataCall.get('dpi')).toBe('150'); + expect(formDataCall.get('singleOrMultiple')).toBe('single'); + + // Verify complete workflow: API response → hook state → FileContext integration + expect(result.current.downloadUrl).toBeTruthy(); + expect(result.current.files).toHaveLength(1); + expect(result.current.files[0].name).toBe('test_converted.jpg'); + expect(result.current.isLoading).toBe(false); + }); + + test('should make correct API call for PDF to CSV conversion with simplified workflow', async () => { + const mockBlob = new Blob(['fake-csv-data'], { type: 'text/csv' }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + status: 200, + statusText: 'OK' + }); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const testFile = createPDFFile(); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'csv', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + // Verify correct endpoint is called + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/v1/convert/pdf/csv', + expect.any(FormData), + { responseType: 'blob' } + ); + + // Verify FormData contains correct parameters for simplified CSV conversion + const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formDataCall.get('pageNumbers')).toBe('all'); // Always "all" for simplified workflow + expect(formDataCall.get('fileInput')).toBe(testFile); + + // Verify hook state updates correctly + expect(result.current.downloadUrl).toBeTruthy(); + expect(result.current.downloadFilename).toBe('test_converted.csv'); + expect(result.current.isLoading).toBe(false); + expect(result.current.errorMessage).toBe(null); + }); + + test('should handle complete unsupported conversion workflow', async () => { + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const testFile = createPDFFile(); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'unsupported', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + // Verify integration: utils validation prevents API call, hook shows error + expect(mockedAxios.post).not.toHaveBeenCalled(); + expect(result.current.errorMessage).toContain('errorNotSupported'); + expect(result.current.isLoading).toBe(false); + expect(result.current.downloadUrl).toBe(null); + }); + }); + + describe('File Upload Integration', () => { + + test('should handle multiple file uploads correctly', async () => { + const mockBlob = new Blob(['zip-content'], { type: 'application/zip' }); + mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + const files = [ + createPDFFile(), + createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf') + ] + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'png', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, files); + }); + + // Verify both files were uploaded + const calls = mockedAxios.post.mock.calls; + + for (let i = 0; i < calls.length; i++) { + const formData = calls[i][1] as FormData; + const fileInputs = formData.getAll('fileInput'); + expect(fileInputs).toHaveLength(1); + expect(fileInputs[0]).toBeInstanceOf(File); + expect(fileInputs[0].name).toBe(files[i].name); + } + + }); + + test('should handle no files selected', async () => { + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'png', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, []); + }); + + expect(mockedAxios.post).not.toHaveBeenCalled(); + expect(result.current.status).toContain('noFileSelected'); + }); + }); + + describe('Error Boundary Integration', () => { + + test('should handle corrupted file gracefully', async () => { + mockedAxios.post.mockRejectedValueOnce({ + response: { + status: 422, + data: 'Processing failed' + } + }); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const corruptedFile = createTestFile('corrupted.pdf', 'not-a-pdf', 'application/pdf'); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'png', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [corruptedFile]); + }); + + expect(result.current.errorMessage).toBe('Processing failed'); + expect(result.current.isLoading).toBe(false); + }); + + test('should handle backend service unavailable', async () => { + mockedAxios.post.mockRejectedValueOnce({ + response: { + status: 503, + data: 'Service unavailable' + } + }); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const testFile = createPDFFile(); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'png', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + expect(result.current.errorMessage).toBe('Service unavailable'); + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('FileContext Integration', () => { + + test('should record operation in FileContext', async () => { + const mockBlob = new Blob(['fake-data'], { type: 'image/png' }); + mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const testFile = createPDFFile(); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'png', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + // Verify operation was successful and files were processed + expect(result.current.files).toHaveLength(1); + expect(result.current.files[0].name).toBe('test_converted.png'); + expect(result.current.downloadUrl).toBeTruthy(); + }); + + test('should clean up blob URLs on reset', async () => { + const mockBlob = new Blob(['fake-data'], { type: 'image/png' }); + mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + + const { result } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const testFile = createPDFFile(); + const parameters: ConvertParameters = { + fromExtension: 'pdf', + toExtension: 'png', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true + }, + isSmartDetection: false, + smartDetectionType: 'none' + }; + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + expect(result.current.downloadUrl).toBeTruthy(); + + act(() => { + result.current.resetResults(); + }); + + expect(result.current.downloadUrl).toBe(null); + expect(result.current.files).toHaveLength(0); + expect(result.current.errorMessage).toBe(null); + }); + }); +}); + +/** + * Additional Integration Tests That Require Real Backend + * + * These tests would require a running backend server and are better suited + * for E2E testing with tools like Playwright or Cypress: + * + * 1. **Real File Conversion Tests** + * - Upload actual PDF files and verify conversion quality + * - Test image format outputs are valid and viewable + * - Test CSV/TXT outputs contain expected content + * - Test file size limits and memory constraints + * + * 2. **Performance Integration Tests** + * - Test conversion time for various file sizes + * - Test memory usage during large file conversions + * - Test concurrent conversion requests + * - Test timeout handling for long-running conversions + * + * 3. **Authentication Integration** + * - Test conversions with and without authentication + * - Test rate limiting and user quotas + * - Test permission-based endpoint access + * + * 4. **File Preview Integration** + * - Test that converted files integrate correctly with viewer + * - Test thumbnail generation for converted files + * - Test file download functionality + * - Test FileContext persistence across tool switches + * + * 5. **Endpoint Availability Tests** + * - Test real endpoint availability checking + * - Test graceful degradation when endpoints are disabled + * - Test dynamic endpoint configuration updates + */ \ No newline at end of file diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx new file mode 100644 index 000000000..3fac5b4ba --- /dev/null +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -0,0 +1,505 @@ +/** + * Integration tests for Convert Tool Smart Detection with real file scenarios + * Tests the complete flow from file upload through auto-detection to API calls + */ + +import React from 'react'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation'; +import { useConvertParameters } from '../../hooks/tools/convert/useConvertParameters'; +import { FileContextProvider } from '../../contexts/FileContext'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../i18n/config'; +import axios from 'axios'; +import { detectFileExtension } from '../../utils/fileUtils'; + +// Mock axios +vi.mock('axios'); +const mockedAxios = vi.mocked(axios); + +// Mock utility modules +vi.mock('../../utils/thumbnailUtils', () => ({ + generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail') +})); + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + + {children} + + +); + +describe('Convert Tool - Smart Detection Integration Tests', () => { + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock successful API response + mockedAxios.post.mockResolvedValue({ + data: new Blob(['fake converted content'], { type: 'application/pdf' }) + }); + }); + + afterEach(() => { + // Clean up any blob URLs created during tests + vi.restoreAllMocks(); + }); + + describe('Single File Auto-Detection Flow', () => { + test('should auto-detect PDF from DOCX and convert to PDF', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mock DOCX file + const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); + + // Test auto-detection + act(() => { + paramsResult.current.analyzeFileTypes([docxFile]); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('docx'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); + expect(paramsResult.current.parameters.isSmartDetection).toBe(false); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + [docxFile] + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { + responseType: 'blob' + }); + }); + + test('should handle unknown file type with file-to-pdf fallback', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mock unknown file + const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' }); + + // Test auto-detection + act(() => { + paramsResult.current.analyzeFileTypes([unknownFile]); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('file-xyz'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); // Fallback + expect(paramsResult.current.parameters.isSmartDetection).toBe(false); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + [unknownFile] + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { + responseType: 'blob' + }); + }); + }); + + describe('Multi-File Smart Detection Flow', () => { + + test('should detect all images and use img-to-pdf endpoint', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mock image files + const imageFiles = [ + new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }), + new File(['png content'], 'photo2.png', { type: 'image/png' }), + new File(['gif content'], 'photo3.gif', { type: 'image/gif' }) + ]; + + // Test smart detection for all images + act(() => { + paramsResult.current.analyzeFileTypes(imageFiles); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('image'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); + expect(paramsResult.current.parameters.isSmartDetection).toBe(true); + expect(paramsResult.current.parameters.smartDetectionType).toBe('images'); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + imageFiles + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), { + responseType: 'blob' + }); + + // Should send all files in single request + const formData = mockedAxios.post.mock.calls[0][1] as FormData; + const files = formData.getAll('fileInput'); + expect(files).toHaveLength(3); + }); + + test('should detect mixed file types and use file-to-pdf endpoint', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mixed file types + const mixedFiles = [ + new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }), + new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), + new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }) + ]; + + // Test smart detection for mixed types + act(() => { + paramsResult.current.analyzeFileTypes(mixedFiles); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('any'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); + expect(paramsResult.current.parameters.isSmartDetection).toBe(true); + expect(paramsResult.current.parameters.smartDetectionType).toBe('mixed'); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + mixedFiles + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { + responseType: 'blob' + }); + }); + + test('should detect all web files and use html-to-pdf endpoint', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mock web files + const webFiles = [ + new File(['content'], 'page1.html', { type: 'text/html' }), + new File(['zip content'], 'site.zip', { type: 'application/zip' }) + ]; + + // Test smart detection for web files + act(() => { + paramsResult.current.analyzeFileTypes(webFiles); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('html'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); + expect(paramsResult.current.parameters.isSmartDetection).toBe(true); + expect(paramsResult.current.parameters.smartDetectionType).toBe('web'); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + webFiles + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), { + responseType: 'blob' + }); + + // Should process files separately for web files + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + }); + }); + + describe('Web and Email Conversion Options Integration', () => { + + test('should send correct HTML parameters for web-to-pdf conversion', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const htmlFile = new File(['content'], 'page.html', { type: 'text/html' }); + + // Set up HTML conversion parameters + act(() => { + paramsResult.current.analyzeFileTypes([htmlFile]); + paramsResult.current.updateParameter('htmlOptions', { + zoomLevel: 1.5 + }); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + [htmlFile] + ); + }); + + const formData = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formData.get('zoom')).toBe('1.5'); + }); + + test('should send correct email parameters for eml-to-pdf conversion', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' }); + + // Set up email conversion parameters + act(() => { + paramsResult.current.updateParameter('fromExtension', 'eml'); + paramsResult.current.updateParameter('toExtension', 'pdf'); + paramsResult.current.updateParameter('emailOptions', { + includeAttachments: false, + maxAttachmentSizeMB: 20, + downloadHtml: true, + includeAllRecipients: true + }); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + [emlFile] + ); + }); + + const formData = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formData.get('includeAttachments')).toBe('false'); + expect(formData.get('maxAttachmentSizeMB')).toBe('20'); + expect(formData.get('downloadHtml')).toBe('true'); + expect(formData.get('includeAllRecipients')).toBe('true'); + }); + + test('should send correct PDF/A parameters for pdf-to-pdfa conversion', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }); + + // Set up PDF/A conversion parameters + act(() => { + paramsResult.current.updateParameter('fromExtension', 'pdf'); + paramsResult.current.updateParameter('toExtension', 'pdfa'); + paramsResult.current.updateParameter('pdfaOptions', { + outputFormat: 'pdfa' + }); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + [pdfFile] + ); + }); + + const formData = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formData.get('outputFormat')).toBe('pdfa'); + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), { + responseType: 'blob' + }); + }); + }); + + describe('Image Conversion Options Integration', () => { + + test('should send correct parameters for image-to-pdf conversion', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const imageFiles = [ + new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), + new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) + ]; + + // Set up image conversion parameters + act(() => { + paramsResult.current.analyzeFileTypes(imageFiles); + paramsResult.current.updateParameter('imageOptions', { + colorType: 'grayscale', + dpi: 150, + singleOrMultiple: 'single', + fitOption: 'fitToPage', + autoRotate: false, + combineImages: true + }); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + imageFiles + ); + }); + + const formData = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formData.get('fitOption')).toBe('fitToPage'); + expect(formData.get('colorType')).toBe('grayscale'); + expect(formData.get('autoRotate')).toBe('false'); + }); + + test('should process images separately when combineImages is false', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const imageFiles = [ + new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), + new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) + ]; + + // Set up for separate processing + act(() => { + paramsResult.current.analyzeFileTypes(imageFiles); + paramsResult.current.updateParameter('imageOptions', { + ...paramsResult.current.parameters.imageOptions, + combineImages: false + }); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + imageFiles + ); + }); + + // Should make separate API calls for each file + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + }); + }); + + describe('Error Scenarios in Smart Detection', () => { + + + test('should handle partial failures in multi-file processing', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Mock one success, one failure + mockedAxios.post + .mockResolvedValueOnce({ + data: new Blob(['converted1'], { type: 'application/pdf' }) + }) + .mockRejectedValueOnce(new Error('File 2 failed')); + + const mixedFiles = [ + new File(['file1'], 'doc1.txt', { type: 'text/plain' }), + new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' }) + ]; + + // Set up for separate processing (mixed smart detection) + act(() => { + paramsResult.current.analyzeFileTypes(mixedFiles); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + mixedFiles + ); + }); + + await waitFor(() => { + // Should have processed at least one file successfully + expect(operationResult.current.files.length).toBeGreaterThan(0); + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('Real File Extension Detection', () => { + + test('should correctly detect various file extensions', async () => { + const { result } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const testCases = [ + { filename: 'document.PDF', expected: 'pdf' }, + { filename: 'image.JPEG', expected: 'jpg' }, // JPEG should normalize to jpg + { filename: 'photo.jpeg', expected: 'jpg' }, // jpeg should normalize to jpg + { filename: 'archive.tar.gz', expected: 'gz' }, + { filename: 'file.', expected: '' }, + { filename: '.hidden', expected: 'hidden' }, + { filename: 'noextension', expected: '' } + ]; + + testCases.forEach(({ filename, expected }) => { + const detected = detectFileExtension(filename); + expect(detected).toBe(expected); + }); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/tests/convert/README.md b/frontend/src/tests/convert/README.md new file mode 100644 index 000000000..e008ea838 --- /dev/null +++ b/frontend/src/tests/convert/README.md @@ -0,0 +1,264 @@ +# Convert Tool Test Suite + +This directory contains comprehensive tests for the Convert Tool functionality. + +## Test Files Overview + +### 1. ConvertTool.test.tsx +**Purpose**: Unit/Component testing for the Convert Tool UI components +- Tests dropdown behavior and navigation +- Tests format availability based on endpoint status +- Tests UI state management and form validation +- Mocks backend dependencies for isolated testing + +**Key Test Areas**: +- FROM dropdown enables/disables formats based on endpoint availability +- TO dropdown shows correct conversions for selected source format +- Format-specific options appear/disappear correctly +- Parameter validation and state management + +### 2. ConvertIntegration.test.ts +**Purpose**: Integration testing for Convert Tool business logic +- Tests parameter validation and conversion matrix logic +- Tests endpoint resolution and availability checking +- Tests file extension detection +- Provides framework for testing actual conversions (requires backend) + +**Key Test Areas**: +- Endpoint availability checking matches real backend status +- Conversion parameters are correctly validated +- File extension detection works properly +- Conversion matrix returns correct available formats + +### 3. ConvertE2E.spec.ts +**Purpose**: End-to-End testing using Playwright with Dynamic Endpoint Discovery +- **Automatically discovers available conversion endpoints** from the backend +- Tests complete user workflows from file upload to download +- Tests actual file conversions with real backend +- **Skips tests for unavailable endpoints** automatically +- Tests error handling and edge cases +- Tests UI/UX flow and user interactions + +**Key Test Areas**: +- **Dynamic endpoint discovery** using `/api/v1/config/endpoints-enabled` API +- Complete conversion workflows for **all available endpoints** +- **Unavailable endpoint testing** - verifies disabled conversions are properly blocked +- File upload, conversion, and download process +- Error handling for corrupted files and network issues +- Performance testing with large files +- UI responsiveness and progress indicators + +**Supported Conversions** (tested if available): +- PDF ↔ Images (PNG, JPG, GIF, BMP, TIFF, WebP) +- PDF ↔ Office (DOCX, PPTX) +- PDF ↔ Text (TXT, HTML, XML, CSV, Markdown) +- Office → PDF (DOCX, PPTX, XLSX, etc.) +- Email (EML) → PDF +- HTML → PDF, URL → PDF +- Markdown → PDF + +## Running the Tests + +**Important**: All commands should be run from the `frontend/` directory: +```bash +cd frontend +``` + +### Setup (First Time Only) +```bash +# Install dependencies (includes test frameworks) +npm install + +# Install Playwright browsers for E2E tests +npx playwright install +``` + +### Unit Tests (ConvertTool.test.tsx) +```bash +# Run all unit tests +npm test + +# Run specific test file +npm test ConvertTool.test.tsx + +# Run with coverage +npm run test:coverage + +# Run in watch mode (re-runs on file changes) +npm run test:watch + +# Run specific test pattern +npm test -- --grep "dropdown" +``` + +### Integration Tests (ConvertIntegration.test.ts) +```bash +# Run integration tests +npm test ConvertIntegration.test.ts + +# Run with verbose output +npm test ConvertIntegration.test.ts -- --reporter=verbose +``` + +### E2E Tests (ConvertE2E.spec.ts) +```bash +# Prerequisites: Backend must be running on localhost:8080 +# Start backend first, then: + +# Run all E2E tests (automatically discovers available endpoints) +npm run test:e2e + +# Run specific E2E test file +npx playwright test ConvertE2E.spec.ts + +# Run with UI mode for debugging +npx playwright test --ui + +# Run specific test by endpoint name (dynamic) +npx playwright test -g "pdf-to-img:" + +# Run only available conversion tests +npx playwright test -g "Dynamic Conversion Tests" + +# Run only unavailable conversion tests +npx playwright test -g "Unavailable Conversions" + +# Run in headed mode (see browser) +npx playwright test --headed + +# Generate HTML report +npx playwright test ConvertE2E.spec.ts --reporter=html +``` + +**Test Discovery Process:** +1. Tests automatically query `/api/v1/config/endpoints-enabled` to discover available conversions +2. Tests are generated dynamically for each available endpoint +3. Tests for unavailable endpoints verify they're properly disabled in the UI +4. Console output shows which endpoints were discovered + +## Test Requirements + +### For Unit Tests +- No special requirements +- All dependencies are mocked +- Can run in any environment + +### For Integration Tests +- May require backend API for full functionality +- Uses mock data for endpoint availability +- Tests business logic in isolation + +### For E2E Tests +- **Requires running backend server** (localhost:8080) +- **Requires test fixture files** (see ../test-fixtures/README.md) +- Requires frontend dev server (localhost:5173) +- Tests real conversion functionality + +## Test Data + +The tests use realistic endpoint availability data based on your current server configuration: + +**Available Endpoints** (should pass): +- `file-to-pdf`: true (DOCX, XLSX, PPTX → PDF) +- `img-to-pdf`: true (PNG, JPG, etc. → PDF) +- `markdown-to-pdf`: true (MD → PDF) +- `pdf-to-csv`: true (PDF → CSV) +- `pdf-to-img`: true (PDF → PNG, JPG, etc.) +- `pdf-to-text`: true (PDF → TXT) + +**Disabled Endpoints** (should be blocked): +- `eml-to-pdf`: false +- `html-to-pdf`: false +- `pdf-to-html`: false +- `pdf-to-markdown`: false +- `pdf-to-pdfa`: false +- `pdf-to-presentation`: false +- `pdf-to-word`: false +- `pdf-to-xml`: false + +## Test Scenarios + +### Success Scenarios (Available Endpoints) +1. **PDF → Image**: PDF to PNG/JPG with various DPI and color settings +2. **PDF → Data**: PDF to CSV (table extraction), PDF to TXT (text extraction) +3. **Office → PDF**: DOCX/XLSX/PPTX to PDF conversion +4. **Image → PDF**: PNG/JPG to PDF with image options +5. **Markdown → PDF**: MD to PDF with formatting preservation + +### Blocked Scenarios (Disabled Endpoints) +1. **EML conversions**: Should be disabled in FROM dropdown +2. **PDF → Office**: PDF to Word/PowerPoint should be disabled +3. **PDF → Web**: PDF to HTML/XML should be disabled +4. **PDF → PDF/A**: Should be disabled + +### Error Scenarios +1. **Corrupted files**: Should show helpful error messages +2. **Network failures**: Should handle backend unavailability +3. **Large files**: Should handle memory constraints gracefully +4. **Invalid parameters**: Should validate before submission + +## Adding New Tests + +When adding new conversion formats: + +1. **Update ConvertTool.test.tsx**: + - Add the new format to test data + - Test dropdown behavior for the new format + - Test format-specific options if any + +2. **Update ConvertIntegration.test.ts**: + - Add endpoint availability test cases + - Add conversion matrix test cases + - Add parameter validation tests + +3. **Update ConvertE2E.spec.ts**: + - Add end-to-end workflow tests + - Add test fixture files + - Test actual conversion functionality + +4. **Update test fixtures**: + - Add sample files for the new format + - Update ../test-fixtures/README.md + +## Debugging Failed Tests + +### Unit Test Failures +- Check mock data matches real endpoint status +- Verify component props and state management +- Check for React hook dependency issues + +### Integration Test Failures +- Verify conversion matrix includes new formats +- Check endpoint name mappings +- Ensure parameter validation logic is correct + +### E2E Test Failures +- Ensure backend server is running +- Check test fixture files exist and are valid +- Verify element selectors match current UI +- Check for timing issues (increase timeouts if needed) + +## Test Maintenance + +### Regular Updates Needed +1. **Endpoint Status**: Update mock data when backend endpoints change +2. **UI Selectors**: Update test selectors when UI changes +3. **Test Fixtures**: Replace old test files with new ones periodically +4. **Performance Benchmarks**: Update expected performance metrics + +### CI/CD Integration +- Unit tests: Run on every commit +- Integration tests: Run on pull requests +- E2E tests: Run on staging deployment +- Performance tests: Run weekly or on major releases + +## Performance Expectations + +These tests focus on frontend functionality, not backend performance: + +- **File upload/UI**: < 1 second for small test files +- **Dropdown interactions**: < 200ms +- **Form validation**: < 100ms +- **Conversion UI flow**: < 5 seconds for small test files + +Tests will fail if UI interactions are slow, indicating frontend performance issues. \ No newline at end of file diff --git a/frontend/src/tests/helpers/conversionEndpointDiscovery.ts b/frontend/src/tests/helpers/conversionEndpointDiscovery.ts new file mode 100644 index 000000000..cf0474dac --- /dev/null +++ b/frontend/src/tests/helpers/conversionEndpointDiscovery.ts @@ -0,0 +1,304 @@ +/** + * Conversion Endpoint Discovery for E2E Testing + * + * Uses the backend's endpoint configuration API to discover available conversions + */ + +import { useMultipleEndpointsEnabled } from '../../hooks/useEndpointConfig'; + +export interface ConversionEndpoint { + endpoint: string; + fromFormat: string; + toFormat: string; + description: string; + apiPath: string; +} + +// Complete list of conversion endpoints based on EndpointConfiguration.java +const ALL_CONVERSION_ENDPOINTS: ConversionEndpoint[] = [ + { + endpoint: 'pdf-to-img', + fromFormat: 'pdf', + toFormat: 'image', + description: 'Convert PDF to images (PNG, JPG, GIF, etc.)', + apiPath: '/api/v1/convert/pdf/img' + }, + { + endpoint: 'img-to-pdf', + fromFormat: 'image', + toFormat: 'pdf', + description: 'Convert images to PDF', + apiPath: '/api/v1/convert/img/pdf' + }, + { + endpoint: 'pdf-to-pdfa', + fromFormat: 'pdf', + toFormat: 'pdfa', + description: 'Convert PDF to PDF/A', + apiPath: '/api/v1/convert/pdf/pdfa' + }, + { + endpoint: 'file-to-pdf', + fromFormat: 'office', + toFormat: 'pdf', + description: 'Convert office files to PDF', + apiPath: '/api/v1/convert/file/pdf' + }, + { + endpoint: 'pdf-to-word', + fromFormat: 'pdf', + toFormat: 'docx', + description: 'Convert PDF to Word document', + apiPath: '/api/v1/convert/pdf/word' + }, + { + endpoint: 'pdf-to-presentation', + fromFormat: 'pdf', + toFormat: 'pptx', + description: 'Convert PDF to PowerPoint presentation', + apiPath: '/api/v1/convert/pdf/presentation' + }, + { + endpoint: 'pdf-to-text', + fromFormat: 'pdf', + toFormat: 'txt', + description: 'Convert PDF to plain text', + apiPath: '/api/v1/convert/pdf/text' + }, + { + endpoint: 'pdf-to-html', + fromFormat: 'pdf', + toFormat: 'html', + description: 'Convert PDF to HTML', + apiPath: '/api/v1/convert/pdf/html' + }, + { + endpoint: 'pdf-to-xml', + fromFormat: 'pdf', + toFormat: 'xml', + description: 'Convert PDF to XML', + apiPath: '/api/v1/convert/pdf/xml' + }, + { + endpoint: 'html-to-pdf', + fromFormat: 'html', + toFormat: 'pdf', + description: 'Convert HTML to PDF', + apiPath: '/api/v1/convert/html/pdf' + }, + { + endpoint: 'url-to-pdf', + fromFormat: 'url', + toFormat: 'pdf', + description: 'Convert web page to PDF', + apiPath: '/api/v1/convert/url/pdf' + }, + { + endpoint: 'markdown-to-pdf', + fromFormat: 'md', + toFormat: 'pdf', + description: 'Convert Markdown to PDF', + apiPath: '/api/v1/convert/markdown/pdf' + }, + { + endpoint: 'pdf-to-csv', + fromFormat: 'pdf', + toFormat: 'csv', + description: 'Extract CSV data from PDF', + apiPath: '/api/v1/convert/pdf/csv' + }, + { + endpoint: 'pdf-to-markdown', + fromFormat: 'pdf', + toFormat: 'md', + description: 'Convert PDF to Markdown', + apiPath: '/api/v1/convert/pdf/markdown' + }, + { + endpoint: 'eml-to-pdf', + fromFormat: 'eml', + toFormat: 'pdf', + description: 'Convert email (EML) to PDF', + apiPath: '/api/v1/convert/eml/pdf' + } +]; + +export class ConversionEndpointDiscovery { + private baseUrl: string; + private cache: Map | null = null; + private cacheExpiry: number = 0; + private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + + constructor(baseUrl: string = process.env.BACKEND_URL || 'http://localhost:8080') { + this.baseUrl = baseUrl; + } + + /** + * Get all available conversion endpoints by checking with backend + */ + async getAvailableConversions(): Promise { + const endpointStatuses = await this.getEndpointStatuses(); + + return ALL_CONVERSION_ENDPOINTS.filter(conversion => + endpointStatuses.get(conversion.endpoint) === true + ); + } + + /** + * Get all unavailable conversion endpoints + */ + async getUnavailableConversions(): Promise { + const endpointStatuses = await this.getEndpointStatuses(); + + return ALL_CONVERSION_ENDPOINTS.filter(conversion => + endpointStatuses.get(conversion.endpoint) === false + ); + } + + /** + * Check if a specific conversion is available + */ + async isConversionAvailable(endpoint: string): Promise { + const endpointStatuses = await this.getEndpointStatuses(); + return endpointStatuses.get(endpoint) === true; + } + + /** + * Get available conversions grouped by source format + */ + async getConversionsByFormat(): Promise> { + const availableConversions = await this.getAvailableConversions(); + + const grouped: Record = {}; + + availableConversions.forEach(conversion => { + if (!grouped[conversion.fromFormat]) { + grouped[conversion.fromFormat] = []; + } + grouped[conversion.fromFormat].push(conversion); + }); + + return grouped; + } + + /** + * Get supported target formats for a given source format + */ + async getSupportedTargetFormats(fromFormat: string): Promise { + const availableConversions = await this.getAvailableConversions(); + + return availableConversions + .filter(conversion => conversion.fromFormat === fromFormat) + .map(conversion => conversion.toFormat); + } + + /** + * Get all supported source formats + */ + async getSupportedSourceFormats(): Promise { + const availableConversions = await this.getAvailableConversions(); + + const sourceFormats = new Set( + availableConversions.map(conversion => conversion.fromFormat) + ); + + return Array.from(sourceFormats); + } + + /** + * Get endpoint statuses from backend using batch API + */ + private async getEndpointStatuses(): Promise> { + // Return cached result if still valid + if (this.cache && Date.now() < this.cacheExpiry) { + return this.cache; + } + + try { + const endpointNames = ALL_CONVERSION_ENDPOINTS.map(conv => conv.endpoint); + const endpointsParam = endpointNames.join(','); + + const response = await fetch( + `${this.baseUrl}/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch endpoint statuses: ${response.status} ${response.statusText}`); + } + + const statusMap: Record = await response.json(); + + // Convert to Map and cache + this.cache = new Map(Object.entries(statusMap)); + this.cacheExpiry = Date.now() + this.CACHE_DURATION; + + console.log(`Retrieved status for ${Object.keys(statusMap).length} conversion endpoints`); + return this.cache; + + } catch (error) { + console.error('Failed to get endpoint statuses:', error); + + // Fallback: assume all endpoints are disabled + const fallbackMap = new Map(); + ALL_CONVERSION_ENDPOINTS.forEach(conv => { + fallbackMap.set(conv.endpoint, false); + }); + + return fallbackMap; + } + } + + /** + * Utility to create a skipping condition for tests + */ + static createSkipCondition(endpoint: string, discovery: ConversionEndpointDiscovery) { + return async () => { + const available = await discovery.isConversionAvailable(endpoint); + return !available; + }; + } + + /** + * Get detailed conversion info by endpoint name + */ + getConversionInfo(endpoint: string): ConversionEndpoint | undefined { + return ALL_CONVERSION_ENDPOINTS.find(conv => conv.endpoint === endpoint); + } + + /** + * Get all conversion endpoints (regardless of availability) + */ + getAllConversions(): ConversionEndpoint[] { + return [...ALL_CONVERSION_ENDPOINTS]; + } +} + +// Export singleton instance for reuse across tests +export const conversionDiscovery = new ConversionEndpointDiscovery(); + +/** + * React hook version for use in components (wraps the class) + */ +export function useConversionEndpoints() { + const endpointNames = ALL_CONVERSION_ENDPOINTS.map(conv => conv.endpoint); + const { endpointStatus, loading, error, refetch } = useMultipleEndpointsEnabled(endpointNames); + + const availableConversions = ALL_CONVERSION_ENDPOINTS.filter( + conv => endpointStatus[conv.endpoint] === true + ); + + const unavailableConversions = ALL_CONVERSION_ENDPOINTS.filter( + conv => endpointStatus[conv.endpoint] === false + ); + + return { + availableConversions, + unavailableConversions, + allConversions: ALL_CONVERSION_ENDPOINTS, + endpointStatus, + loading, + error, + refetch, + isConversionAvailable: (endpoint: string) => endpointStatus[endpoint] === true + }; +} \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/README.md b/frontend/src/tests/test-fixtures/README.md new file mode 100644 index 000000000..32e64b346 --- /dev/null +++ b/frontend/src/tests/test-fixtures/README.md @@ -0,0 +1,132 @@ +# Test Fixtures for Convert Tool Testing + +This directory contains sample files for testing the convert tool functionality. + +## Required Test Files + +To run the full test suite, please add the following test files to this directory: + +### 1. sample.pdf +- A small PDF document (1-2 pages) +- Should contain text and ideally a simple table for CSV conversion testing +- Should be under 1MB for fast testing + +### 2. sample.docx +- A Microsoft Word document with basic formatting +- Should contain headers, paragraphs, and possibly a table +- Should be under 500KB + +### 3. sample.png +- A small PNG image (e.g., 500x500 pixels) +- Should be a real image, not just a test pattern +- Should be under 100KB + +### 3b. sample.jpg +- A small JPG image (same image as PNG, different format) +- Should be under 100KB +- Can be created by converting sample.png to JPG + +### 4. sample.md +- A Markdown file with various formatting elements: + ```markdown + # Test Document + + This is a **test** markdown file. + + ## Features + + - Lists + - **Bold text** + - *Italic text* + - [Links](https://example.com) + + ### Code Block + + ```javascript + console.log('Hello, world!'); + ``` + + | Column 1 | Column 2 | + |----------|----------| + | Data 1 | Data 2 | + ``` + +### 5. sample.eml (Optional) +- An email file with headers and body +- Can be exported from any email client +- Should contain some attachments for testing + +### 6. sample.html (Optional) +- A simple HTML file with various elements +- Should include text, headings, and basic styling + + +## File Creation Tips + +### Creating a test PDF: +1. Create a document in LibreOffice Writer or Google Docs +2. Add some text, headers, and a simple table +3. Export/Save as PDF + +### Creating a test DOCX: +1. Create a document in Microsoft Word or LibreOffice Writer +2. Add formatted content (headers, bold, italic, lists) +3. Save as DOCX format + +### Creating a test PNG: +1. Use any image editor or screenshot tool +2. Create a simple image with text or shapes +3. Save as PNG format + +### Creating a test EML: +1. In your email client, save an email as .eml format +2. Or create manually with proper headers: + ``` + From: test@example.com + To: recipient@example.com + Subject: Test Email + Date: Mon, 1 Jan 2024 12:00:00 +0000 + + This is a test email for conversion testing. + ``` + +## Test File Structure + +``` +frontend/src/tests/test-fixtures/ +├── README.md (this file) +├── sample.pdf +├── sample.docx +├── sample.png +├── sample.jpg +├── sample.md +├── sample.eml (optional) +└── sample.html (optional) +``` + +## Usage in Tests + +These files are referenced in the test files: + +- `ConvertE2E.spec.ts` - Uses all files for E2E testing +- `ConvertIntegration.test.ts` - Uses files for integration testing +- Manual testing scenarios + +## Security Note + +These are test files only and should not contain any sensitive information. They will be committed to the repository and used in automated testing. + +## File Size Guidelines + +- Keep test files small for fast CI/CD pipelines and frontend testing +- PDF files: < 1MB (preferably 100-500KB) +- Image files: < 100KB +- Text files: < 50KB +- Focus on frontend functionality, not backend performance + +## Maintenance + +When updating the convert tool with new formats: +1. Add corresponding test files to this directory +2. Update the test files list above +3. Update the test cases to include the new formats \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/corrupted.pdf b/frontend/src/tests/test-fixtures/corrupted.pdf new file mode 100644 index 000000000..fcfa08529 --- /dev/null +++ b/frontend/src/tests/test-fixtures/corrupted.pdf @@ -0,0 +1 @@ +This is not a valid PDF file \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.csv b/frontend/src/tests/test-fixtures/sample.csv new file mode 100644 index 000000000..9c139e4bc --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.csv @@ -0,0 +1,6 @@ +Name,Age,City,Country +John Doe,30,New York,USA +Jane Smith,25,London,UK +Bob Johnson,35,Toronto,Canada +Alice Brown,28,Sydney,Australia +Charlie Wilson,42,Berlin,Germany \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.doc b/frontend/src/tests/test-fixtures/sample.doc new file mode 100644 index 000000000..23bec4b35 --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.doc @@ -0,0 +1,10 @@ +# Test DOC File + +This is a test DOC file for conversion testing. + +Content: +- Test bullet point 1 +- Test bullet point 2 +- Test bullet point 3 + +This file should be sufficient for testing office document conversions. \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.docx b/frontend/src/tests/test-fixtures/sample.docx new file mode 100644 index 0000000000000000000000000000000000000000..45fc50c237f5944add91597baff430bcc853b049 GIT binary patch literal 32303 zcmeFZc{o-7-#5ApB}JN4WC|q__Plq-lFXEBLWVNW%1nmRs7wh_=1|)dlFHmb<|zsp z3KcSB%($QT+I@fb{kzX|U+0hKy3TdZxt_;mx7TZZruXMPulHx^lqL)7E@UIJ2|q8mu>e3>t_t`Boh2p(V%pcb?3mQ26=$aniz62}FNSn!W z+sKSIt90rMX2HjV13bG~?p^t6pmrv1*l{6N@t))f8`d*jO6o7ReXzRhk4tdozsAvd z@V?ddbpJH`^ai`#o5!mxG78O#ZfTmIsr^IBzS7UNk?X$6&gyq^2f45U3!3Q7bcI(GSIV_u z+idE^9J_-}SLmXuw@fyCo!J%U8YK7b)>TL3SEB#+E*W|m?|bQ4v&hZvwoZA2GkaCX z&2HTy@h418JB24|8;W}AlzTFnZ>2{RaP5(Q>dyE1N<`sGzU;xY`R>_cew?hgVx3<5 ztJW%le$nYH$f^HgqykaAB~#F%12(t?jMV&+wUfKF6#DP~G0y*uE%M*Co^!7njFUrY zDr7CBt^9OP_1<(HY0K8$z1?h^Ow_e@_I`Hww7w?y`7^WW=y}zK_KxUQKleu`JqPcb zPV>f6IYZ0YyOzw}nyi%hATig=GB7?}Zr?Z_eqXRzs~1rhuMfFn#2wngyD{p|tp`n2 zJ4X(mSQyFLVlOP-^3hVXC_U*S_0!^mKwL^ZrnAdI&BULLCa+R)ga36iPvB-7#ez88 zk;sTQ{e`CQ^v9xKV#6H7=OuZaWTX!~in@@qnRmLZZOkvdGVA^A>yqrL?6;*?F58^j zVZ8n$^gE9tacaNObMvpR`yQDdn}tyIKifydc~SkTjR^8f1VOd~Q@mX-SxH;DT6#J| zBtt{kFC!DzUh?iha|?RN;G%xo6WIg~r-u67Ket|efm_kie11fK$A;Tp_l$l84Y;eR z8w_O@Bzn|t(F(q`ICWW$6g9JAE>QpG^Y6?i6;Jv7Q#rS*v-Pcfiz|{=7Y@AIeDI`% zQB1*c{Z+r2550BLq#%0l%F3agqc4VI@JO0N`pVJrB&WwYA{|a0oYF=sd5s+>pIFVOBy{E>LmN>F&|$No0u$AfZ@ zS-cN(@t^xxcC|XjPUB!|cTUqif_ce2H}XmAdqdfW5fKYxGp@HYW8*w`Ejoiwwg0kW z9gtM1*^)4S=$l`mGwq2<5Ftl(EXr(v`|V~kKC#{Jt{)7FRFPOIZo<6oXc&?zIFuXD zDXkQd=0Nh5x4JoV4!`%yoKUHtxP{^FwX&52J|2gbj2CAgp6Bx6SZnsOPK~GUjXJA( zcIW;Z+_k6v7}&HtX65SNqM%6-8M!ACt;=O0i1>&r&RDluvhv@EpG@C(aYp1Vt+vhJ z*rUt&XD?Z8y{uuw8tJTi^_hJO%a(HrZya#(&N?CE&Q{-y4|d-uR&KR>*BNy?Mb+cv zt)A-5QCpWdFiZ9t?rc@Hm#p6Ut-V$%cYgPxKYb)$dF7dI_o9!_QKsxIe=hEDFjxIP zd!sY5t^vRNEbqkI*bOpQrwKyLoYqX2uZmcv(pHbnr_()Rs8?c~94#~+eW}{-CSIs2 z>A2S|ErIZhHB?lY@UY_8D79s}e0myfT+-*!XIT-d3r0ga@RooUw^{8t7H3Cd(~xJ0!MbnxbNoB1^N(!g&WK3C4KoJo8bobuH6`X2iYq=QI~-QBu2QT9XX zQk0ohm2It~!Y|c|kK)8`?P1UR>i2VRl- z)^xluMYrE~cYLk8YR0cSVRE$(Z&o{TM(E+5^XSSWfk-n*I24_NQh;bm{K?r*BC9rhJc- zsygbEN)t5TVO)4ylG0*$G<63d| zsTK0aQ>!OTHADf2CEC+ zJUYhmJ;y*UW{dpJeD9qs-UJNq{JC#uTCQpSFtr?hLj1N}c8-6~FTH{ta^o+~&mYv~ zB;#z#w9g-Dx@+=swz!Ni>HZ31e7b7FKW2{#Tip8Vaf1V#7AaTvo@-u^7gSj;o>1JJ z{9F5UP+j)B%Wr9fJLRq;g`2ldC(FGRl9l-->yN1l}P z=JxcB9A>j%(f?g&ttz4#|B~$_=E)4p(+1)Eoe%ETzj@tuqU_uy3nqbZL5sadr)v$o z2)XZrZkln#UATE}2^Zuy()z$`15U9lE%4Xrqg(i-_tUF8CtUce=^UxD{>NwU3W_Pv z1A8eQ4^p%F)G_0l?>j%19&68adZ|S^tCD+}neC(F4W^8ka^Yv4T|L}_2HLj|$Y2HZ zB^A61o)rJStp9N3y=O|XF#EMG~zSH8Zg>Mbag zs_@BWdU{`LiY#|Nz0R#*7hx`BW6aCT<<*jHgt+4xrzd+in1`JtJ+kIIUR^R>dgl3# z=-tuc$hm`fj?lg6SrQu1V2A8f{F_=2Ukd*M1Tm z9^(Gz=(zZ#u1f^s<}qmHLpCunqT}Bb^50|RztTx2c-Dj7|9k(G{rj^{MeM}m(U1?_ z_m98GU1189k5$QKYscr(D}?ulDesKF{`*qi1KFFhA+gzlohMD)%y^sBc*R7SO%pU6 zsz0dy_FGgFjyPvnM&R8$S`|$V^e?&`5^HHU6ZTwTvs4eRU8u)aBcMC@n*wpShtJcn zGknUUti@%k!_0QEqFct61w<8p-|gZSaWTEVq+_`&)V;8PvuLhv8rnAwfr38 zkCFevBER%!#vC-`UrM(5A1&FB6i_b8+Y2EG`r(GV3?*~6wz5AjZSQ>E)*2(_=3;yM zl#VLrW?pm@r@Gn+Jp^G2Mn9V%cS9c67FWU-le?bkaU`#qZxjHl?J@0R2vQKSW%WF0 zFT(DoX5@|_vRly)Q{#)&5CjoiS3hw~-^XmCPxdFFk6Qct$YXq3%+>99AMLw)T5B}g zKuRj1Pp+JO9~^e|c36sq=+*1jtG5X6E{NNEs`br@En7AmDHFOXylJjf z#YgsNidPzGEXBvQ2CYo$)f<)s@gR{mH~Mq_3^LPLD$%Q_bG#F*5M@8I1=)80@MY>0 zZJ6-0B;*z0{)zh>b38~eqMzt!A&@(xs+`P@oXs&zdEV+DGUoUwqW^ZfM11!YpWYj$V0>D|-P|za(M68sMZ4y} zu7yk7h|qJje6#*7xyd^|-rZAZPy67%@=9WplG^;0T%*o>+b2Dro`)6p45W|v>-smT z86d5>1}ikJ@@&(o2SyW9ZR_@*Z1Fxy992)TIe&7-H>+JhlFr4N zkXv#V1W#ifK6#4%F;Yh~Pfe_}`T5SN-5*3O60|xix^TwDK@nX=eK(>8V^1M@*z&ta z{EF$fRL?v}j$$1YK2w};;}GjW7obi}yqLd{c@MGJ)VcmjZgTSl-c3qzFhizBq2bEn z&tuQM9{PuXnU=bkx3q2*Zu=J6xh~%&np0YYSVU)TD%4-${gZDzF<_av@mrq~VkXwn z@cHA1%drkYcl=}~6h_&3tC0$!-t0K)gyMLWx%lall`JtIAr7Al@n$ud?;f*qe{JTT zAm!e?7r8KCbVj$U_n10TwX=T4?}qV&|7vTfiuQfgh4_1;G<)%YlPgQ*OoQrn+s+Kh z_!Z~R-*(BuLsZ6#P z!qkbD8*p7n-~O~makkE; zWx_SCZy&OwU8ttv^YRJbLZjrVV(A`h=yn22UD+OJmtnA?aXe}v{!>IzNy7vuQk^Jo z-L~^IZLM3|G@_MX*I)wc&!TjSmQwoyOnL_B_F%7 zG2?j1nfrpqPgf+Dx!cw55<}+8pEr!L^9Lcje+Sk!=G>Ya8#V~7+LUi{ zn(0eaK~IECQ}oxE-95Gf<4=Yxug&kN_V}?oSRH>$CI8&hg_ZOG$ESH?J&GMW)59zR zTqg?sj<4`mv35nf+kA9Te3Knlg!e3NnJlkWTPawZmB%C?Ik#`JB3c&Z_+;*gjRl>B zUl#m^C$n%ng1c({GOPkNGASvG**rg{9r8eDK{e#_VIJPE<=%>kQAiG_g~~O1#X-HC zkSm7hF7a<-UM|wsj&zMyMA_ub@y7%v_~BC;FXOmvy#1}QRpoB=k^ zZzSa%@0q{5EmlHwEs;k(cTwFULO6X9$HeD$lKCYk=P6s#qTJ-E-yFGj<~=)m4n|z@ zmhh{PjtI!ERJ?~U{UXaUeQdSJcp8d3yYIf)`KJr6O?<2V1GPg1uJLrXgSYP?4t4=2 z2M9O~7S~dN?6k$*&GI<5AeEMniFvznIxDY^3R>K=V;>|MXmQzBIVpOpe9X-IvCt&2 z>YsH%Zc=7^O$*@{77ON`uYSe@gp9K1+b10n=r|IQ_M^UWs9IWOieD8uYNdvhd<~!u zp3a!OPCJ`h@(JIvJAX9RKlPm_-hQ;jS3A4ui)~d$LoDLpLkP1~m?ISRxHf57X-LYm z6nx6f>Ip&))+ZniXA^3=%%AjZp30ouE>L3Jf2Y5CVAeO(s3Q>@2OO7a!@!&a5%X zyuQHvGECKQLl=`oJ^$gZ2|H!=iGGFNZ}NX|i(HZyv=Q1z8=_EonlrC|BVE-;Nu9;B z)GYmUzKx%c&dSGWyGa&uRRZFY#g5-IVZ`2is|UaEfHd{=p6zcnD~)jZ4*8gk4pY^N z^;GWZU2;2>G7e+kQm-8F8@P?TGk+yJYT{I-GE4E|V9S}xstdRsN*U3Vx72F26HoPX zb+L;TcG?3a^ABF_DVr5M+vtZ?)Idt`r+nr4dH4JMsJAtWL2RgNgp+|bJA&J2tfxj> zY?B2-@l=c~lbtLzzNIX*1GCpUh*|f&--X44!6rsoH--B$$r4PBd4C+|6kL(2C{JX8 z?A!UF(Sy3pKCdSM>9H~hA_p#1nTyox^G@H-VXs;-_`qSfA))Zr%wt-uJEBp+JD1yW zrF^@16tiyK(a(Nj!G|?F{Y*%k3AuiVO0=xn8^M{tjkQ-;&KE|-8)VJf!2=b2uPpour7tt7Ct-5oUebC@W z_SN+KECKFvt`u=vd@q+pr02ApER%jqKR(<~<#9DVr+<@-hDywG#8EG5*^}0fud-*g zIv#9ZBQ>3DBqztc(qMj)RBX@p`*6ySo5NRd_Fr0q^91^TYI7g$CoK5L?JI8V+)96>=o%#4mm`N`7D{auO<7`an#u<^`^kbDevY4h2H7ukUX=~2m2f{fvM!b<=x~w8T zA}<|3-6u^KsOwCqDbb}kB#%#tcaILt)A!D8uZy9rsiscIURr;BI}%Yir?I3}^raX6)9V_!Rf7xK_MiP_B5ZK|1?j;=Tsn+m(khR?mr# zl=g6u<9T!H2Pc+K-bvTCtlHVR{`k|#7i zh%20}D7!ynzOTV}J55bt*K_+vBQ%R)*;-F|$Kp!WhQ)rdNyA}VQDiOAi;^ue6J2vO zzGpscH$LoGanRi#eZiAlw;-HV*jQ3*Ys56f1rKGEWtg@_TR#v;vtK>;WTDo_tgqB; z!&$ZmQLNM7!$wZrs+7)H)vL;8PN*T9WICAFiGPdW_AS@a8wlCAqbmFNE4}(!4e=?7 zjLX)gGhz*HRHcqTyoClUr|pCM9+mbOkh-{e)sgLfi0V+XrudX}Mp`vp@keAn>upgp-dUygz3Bn6D`+R4 zU7tswD9fJjX0s`Eg*Woa!l&PR=^g^Fo(29EF|YaLEGee>)9N&r2sWX{N7SS^-+j`d z_IB+O1-mj63XxuQqZ}@NK~( z=6J^+igosa=Uubhp32&z3$L{@j14b{GFeqJpL(enRkBHYXgs~ArP?_1RoT7|e!+Jy z+YdY|cy#wS6bcV0clROqYjhE@5k0O7_L>(jF>vD6Sl< zRnPgI)49GIl9%;S_J9GKt`M8yJFA&tBUX16fu%;J7wfF;c(dOGER3caPpH!KcD2z0 z!i;)?j0HL0*yhH09+q=vZq@wMGh!9yUA)hGJ3egaX=lZbl=(8#?U3EQ9thWpur~|n zJUWi+X_?~0jbFhRrsv&90*m`z-gz2hQ`*<|4IC3~4a56Taz(rL~)2f~U7TP{;K_6!I&zG$Dyw+ftUccCp7*A}Fh>G{r7%V4qDF zKk3r8GG|Ll{E&Yy=jG%0u$8B2=O{i#$H$*+_CLw}Wt@2a#rP{>xy56pzxrj01TKpP z>-G1#4DL*~U;W%2V(1DI{6&)>me>p+l+!?d3WAZYdUau+@w@{ke!3_H?1P$z{y%TJ4toz19mix_Q_K zO-QlP6g8w)Ub*9eL(sVw0k&vj3}gL7nJ!O&P}$g12Eb+UK@g^C2bG9QvOM4WK5)7S zyxNp2W18=Et@!X=^NTU6Et;-|559MA;ZGNN?%HELf2|?+Ntd+EoKmUYbyemE(h#`s z-a0#B`=GfaaQ2YY$(EpT>8qocWx)0R{QA_($jEXIQL600zYr+tR_@K;eCqbaIlj5r zj2>5i;jT9qo^N+5UpltZ)3|IMCUALKzK`C2)~x2`1?DGBN1Y!Yr^_vywxmU$6?0z+ zI7bmSSm8-{-Q&90e~u>Fv%ZwHR~uROhs<5rlQ}Ybjwvq|nM>@KC6_11&b!TLUVP`E z*x;Dm>k!ny!>%-L$?sp{qB&_}dRtZ2tA&je8;1c^7T{bjIUiu`6)QmqOQ)`+4Nomu zO5oBCHm0vk+-0?JMkK=JQeHMJs~GR7Dv8ru_q2@f{-(ih@%Wy?^7CIBMh|N|r$g~0 zne(j}fq;apiqH9rXP-B}kHA~zCO;i|_dcFoDbN)ibC|u#&&Y4ez8hyR(@MQ!U25>| z=OE!>Maxe^{qzn?Gwt!HS3Ztd{#Rq4t@{4-C-%4RSsV=;u?fub$7mj#r)u)YQr;FAgKa%&@i_gWmV`*4-_A zdA@CCuUa;{m$X;@q)kF6zWLNdNt!;r$2+}qJvTliXllhrF0=E?clvvHviO;}CtC1u z#%gCxVOlNsj^J8n`||VUmmZ~>)lM+X6t%r($J^z`g-SORXKy7W`|@K zn(1R2gSw#W;G3eCmTUE2nqnkM&uaG8%u$#_~?Yx#QQ^jz77kB*h$jX837 zS*AmC2O|7z#Yf^a#py2#z8nJMi6}~_nedxC=_L`)5|KG6z<3V9OxU%zC(kArxDVKx zNxaz86}{w~aX(oyW3{cOux0CxsxN1|@h*y`q{4?3Yb-pR_-d7W*>WH@>B`w!*V4OD z&vvzY`{sTzd%1eKyP-vWT}gbTlH~zAm+q$0g zqw~I{xoq}YiqY3{Cfa62$fn}LT8w#?okM=faeeH@xcfa>%jy5nIBPJ7a}7%Llw91p zzFcH>TW!U?C+5tH?BFiL7`=YThkV3GX7?q_%J%o>ojP8mlMs&?y7zMJE({&gkqHnJ znHu?xx8!w%N1d{S1nTr^a=g1u3C3T!|Fp)^t=Z;*J5%Vo{@!<1_E4%=e436FZ6{+*-Iuy zqrHwwPyW%_FJp7xlmNPA?VMD3#BJYxSLqUgLxeOw8NbEW{QG(eMFrvh=bKuW zOJk39SXO1{42(~_ZhU>|%a(}jV0iA2ikWYY@4w<6OJ%n(=rp$+wmlg4`?a&j$j&7H zem#vP(3hS2#(kv&pPQ<&@hf#j>!gmowG{L_80=6JGh}vlMRQ`tPej7;O}Jievkr39 zwK%tHZT^slj>+bRQ90g|C8>!O&pXP8CZxyby2CtkQ_bES;{2S%c4|t#-o!>USfe+4 zq=gpi^46GA+%O(?SB^K!R%f(70UjI;R|#^>BL~Xzm|c|Pby-w)jd8C!;c6GfGVXg9 z2R(~c9T~XcdDOv%`DMmVkMAp4IUPzUEh6LI`Ooc*_z$C|KL733 zyc06xSKLMMPmFYas@YVg8C;y&;ngz6>ef(d?-ak$A+B>DkI`cJNa-T6R3?!-YT@ho zY;UiH?5jK-HYS*RGA&w2jA4flo?W99Y^G+2FP!L%Dg7|BHNyP|UO>#r^@Jm@U_sh) zern68_WkWUl&sedh&3Fkfv_X8&#^PZ^`_MNl6&@K(f7u}fC0?BSKJ09EGC z-WhCFlWo3sea%s)z7`jL=BQg?Z{fjY+w+OD3)pWo$u|f$v*%M`e$X%!!>+pd&L*2x zt)cQ(pDcHJyZc&sS<>_)elx=jNz|LgSVKOZNAeq4BLWPgkne>DFJJ$$DBt@_;u7~g zj#XKYDm$^<`kM6fP-!6~k3A3d%rhwPjv2c6>Ds-rPbn7iP)Ck1ifFsV70mzD^j7$) zo1B?fPq+7nU+>jA&aymJz1o2t6Z(ESm*4)gZ%)Y@OXDzBnYiL7WNhxECFO>-P? z+sqf+*Y(;zdf}RI>I<0#wU^5;j5A3^yesr33>B}Nw5>2J?x>Oz;0i9Twe{)|d)?ke zNqL+ytNr87x+y*4SWv2#wk1E`=Cz_?8m(Y{I#xB>E1%~V2AO={W>0{TU9uk+!bPf7 zpoU;;XEH_ICt#@RAM?=D<)xT_HW%=zAh1?-Sv@$LL)$XqGi_aBJaJ!W`S-p?`xO5I zLhecC2erm(&)38);w80*qFQhJ<}dGwU~-t6=^MgHp3dK_hPeAQ(%pMSj(=#yPu>=B z9}+M;=3LxY*u35UnrBZ0>$9ez%~So)`Q1Wc0@c*e#nbcVzK^O@`+KA13+^E=j9Q{| zxPrg3TRHPO21N7=k7Crs7w(1C>Q~1fWBzQT{i~5KaviE8{n7Qt@5RHlNWOAcE*nH5 zW+Mdw{v9`!4z)e3@edm~BET}+rs*3Yo`t*0I(;}|Ak$`D&|bxncc5Y_E3VdyPd>gl zTp+mwdvamCb7yezt4eP+p2*|ZG7EGLHcWU3b6W=>zdkgvDDm17 z!g50@eK-u#xf3&YH0z}JA7l23A^0xt=1}6zhP<3THzdefK9##G)9hn5^Ny-<-!Gjb zo?n>HH#zZ2RL>DF3;woi6>#1-WfY?nv5|dnUM`%w#d6Q4mh9KLu2pjiSL8NIzW-q1 z8xcFwmi(-yZR6sRh&7&=;wQfk?+W(N?{hdcuD&tB-2As}YS&H^%=qwvMv4gLTRmh` z@^cE=%;)shxc76G@eX=kut%srrP$XNV=8%vFS?do_`W(hmRV2{z1>h75$Gk%{y{7( zawm611m3Lb2+h9QCcztV1uGoE#-^J25s@5h8$6R!*5Yr|;WI_zntbKJ{A@k^-A``r zV3ji3SZCZagvaz=w@%h77K^I)MuM~X=Y8d`$JEY6S|1+QU{~r*>%Ws%n8S5+2HCsq zQSBi0E%uh*Gkn;QkeFHpxrZ-~tyC@FaVWv~scf*=-|}=9^_%U%E3_Z9!JiRhU!o~Y zhwR5zN(@$8LR1ghM@JAKSHe7rTc zEpo@bjpav6az!(O7GG*(G?^bzCtOt?PYG0Q<-DMprRAp_a-~*jA9a{D#j|6g0++p( zYq2hu^vsaW5owB5D z0=Jzyl#M@m?eiNt6cBs(SB#;({IPkibMJnhDK?ABu!lP17bs<9`o1`RyLVY@L$Hzo z{`z7XF)V$nRM8Hl01F9+VsmA6b;G-elwH?&oT~7Jj?YkcU3s1ul^t-st7ZKBQRIrs z%N;Mn)WC9CpM9I>FiX8^F_Qm@X_H0GxUk%}Uzr7}%pde7+;4JCzbgw=aXR^^6ThH_ zoQ5*q_r<3ROqZ{046!?Nyrkf@{pDiT6bJK_2nWF`Q`(F4fz)r^_^wN1a^)NAmJQlC zW*1biItVJ2aNMqH$9Dzq9i?tbN_B3Z70_QnwC0FyMS=pdJLI*8es-9qA1mgv*vxrY zUa`){;1?k*pZF)c=Tns~++{cAKH(SXBryq3Q~e7)`+5{;0Z7*A&iM0wPl#bHmwA#@ zfhQcR$eRx`^7jdHLF0Ro%(`}bxIl5wr}f7@Y>^tVnHQfA&T}AAXdUF&;BFlxE;hry z?C_HXCb!!zs>On4KPvIwgSEAgP*X+oiEUOfgqn*Q@DfzMid;k1@_q=}xXTOMd3d-Tm6rDQ_LlO-Nx5FKmBz}*$VkJef4Us?ak6)D{O>wqC=`k` zqg7bQ-b!9r-O|;?-5h77>U7@S-P&DP$kogGl8uwAHw?D7va)s&R*-W$?_nndeYG(d zDIyjtL?B4va5y2Xj1-1M79xNkT9MUIr(GCrV){Safo%3`Ph~kRnmATIj-ff)E}ng~cgjWTfyY zz(|pBLa-o_h>(SO$O@@KPi)P77KHK&y-tg@jWEfTsu%&~9WQbOLC>OOat0|7B0c z!qWe?T^tb>#|se&Qe+GsB?U(iB2r)nL}dU3ES`u$JOM*y(22qR#S{z$#T3{K+6*(m zp2#@ze;^W!r3ewpQg|R101hiegdSL&7K-2|QAUc0!zurRfRQ9DgE0gW0p%o#$XE~u z+XsXtlC^+RGEf2gBgqhz0TJ;yJPL_8paL+SK!DCzDFQ_Xi()JhW&%hi3*k^{{s+Lr z5SR!jMFi&IPy`(bAW4cAim<>AkQ1Jw%s?0+A`ZAhK_?-A)PPkuU==LzpNuos#*twf zkUEKR7mto0p{hb6Vw3^lNd(}ljFb!xqym_LBa={!!;=5ve>j3AYB5@&5if-$!=hx^ zHIM*Gg~ceNtDvk0D1#?4T8UUPFcb8IfcppkC7(e2zX%%mA%w$#I>R(l7(9WDK_M0c zOv3?Pj7BU8Sc#RQfV7|u2Yf-584nDE1!Pc7hjnE@XK`>JCj$x%SO|yHVi*jbj8Vo& zQ7B{>fC`OFp+E;b84uF|V^FDpZZnvK!(p_5<3wm614*D%QUtIk;0Osc4_FH_1|9%? zGN2D6DGUxr0S00~bw~gSuy=INIII>J5e@`Rg!u_X)Z9?U!kj=P79<5015FqVQ3wmO z;(?QRbR{h;bip!Kpb)XjfUsDY1PcohVIWxwCxfyTc8LK#p%|qRlu3rDg#*imt;4if zR3yMsvJeUGfn+F zYXBqwPf&zGtqF&!3JS?EHP|Er0b_|IW!Msq2pT{F*<*oKK(P!N<^`!xU_!t^4r8wb zycXQ0{9~)IHW>sD1`pI>mC2wLIJ61aNdR987{f3Q^c&;{7<3Ws3PuS4)`UF(q=1G2 z#$$mMfblXUJOg7G%>*zGv=v3@hRNtcfKV<-;V2l!LLe{3jnK1C zARNL(AO|3kLV{XZcnlh(fwsy%pgCFsg9ynju_aL!<|hGHDn@C z2SE}G{+9uv6-+_~A_3Y62=swWP-Y+!$`=e7loBSyU~qup8;8ya62^c|F)+Fka2;+l zAm|ArazLyB42~8s5flJoJE{P%0I&oAssQLcQ~^L73fU0{q27Tmx$XHo)u@Jg@NjQ)SU@{2} z0+1OoG96GGa6@Ppp!Dwsw9qTYrqJEXU_j47J~&k2(AWot3yVUmV{nlO){RO8M_@#1 z@Fma(B!Nb1$dUd+GU|vSdO~DmC@bm&(QQyr4Fnek*3ZBsR71hVq4Gp``B&OxECr@P zF?=zEk1oYP48=f~g1#^XBV;kgVu`550hxbg1v>%&aTEQ*f)S!dLIJ~v`To*|VcZ1- zz6=-+nvCiJ1A!;TVwL|QkScg33@Q}@><^VP#4=QRz#rU~FcP#5tR12L#| zLTUjROa8}}P(aWE!9ql43;+)bQm4p3KZewS0f6x&bmyo=x7mA_GbWhHR{jW}sk65cj~LLgopwhiej~P!QQjWbh0CiDcA5U_*E`IH3x~@M0v$ zXCOr4@mk>0WFUr+Kz5MGqWp!Oqv<6X7y@pZk!vy}0a+Rm5Za$X1nN!D6c!J9ftn<^ zSMWrj7T{qZVnH5;rURg5&;lNw2y#Lb0FWXe0_+oz46qSJjB9XOaE%950H#sUZ1Eoi zX(tg}71|MvQlRMs4B8oe5C9#;f;EESqt6I3@F)Ob3YZVxok+r?xe4)a&>%ue3J6W) zVB;tNae=J@0%CY?Tp;fi~RVm_{Lhs{|s^ zUBJTu38Mv@162iA03HAm4+un{@Ms$=h|46B0{hSiZ#lYr%6F4XEEc%fE@ zCg@;w5I)cd4*Nr`4iLlYP>5O`V20J9{6?(~FvIF#FvIFlj9MLF)ap=(S{({et7Bl8 z91XgRrBGP|9pH}9rJygwB49iLR1Gl0>QFL4ngGG-0H9U}F%Pvb^eF}2VRfLG46B2Lf?;(G42*_6fMIoiF(d+D28@Ir z5{hU}E#qCqdu(Uk!p3WOZk&#Z+@W_>(~Vuc`oQCBy;n5V*roQv)&0mFJIZR>PuWd`wl}Lbw@|8B zSk*3ZnyatI^K5k`p(Tv})t_(DCA||_y%P&15$o%1`2YmrlPtJ4o*pzXpKQ8F?^~#B zp)Zz1C`trf`SLO{_16tXPcmVee^2)pe{VZ`uo#brV2eheZ1|_96It1%ZZZyhk)o zetlS|SXmGl_9*7~w6L;5g1GW=x5)j1O%L|(_jwZ;Vbc=1?bqI!HVvK~SGl?t9N%-W z7sx3q7|U~o7d-y5aVm(TJ#^&wrdW$I^JRC%1;t$tw70H%P2U$-Y+2*lX!CVYTE0a+ zc+{#mBu1gVpz!#iJz6qyx(gclw(*DFHdGH_bw|r*dh1*_-H2u@<6F&`iWxW+s42t2 z<)iNGzQbY@<z&))e~K`-FojnU9fg?3*f{u3k^INRXb z?9P0yerH9y=Sazq+vlaCq|K5Hz66OMNHh!#aXY-PRDI2gg`zVwC_Nt+jbu$@_dIPW zb4t=P8#*+fm~~zJtGK!S-7>qR%$)hSnmQaa-ttuz-puzTQqdGDXx})~d%c8bF|MZh zGUAGvlnzT?Ldh3{8Nka~!wuXl)8)FCm3Y_hEPw7_46aQ{L_7JU2(pB&BAFGah zu6n1nsHOy7%iK(et^FYnl8>ukH&o2p!KGc?rVMX z{i>UXvscMl;CJSOQG)N*-%_<>+vfL6%XJ44{p3rpZ*8tmc6Vp~8M0I9;g<~@&7JgD z6sEaq_7u~~U-U~nA=&iVzpbUvTHi@u`rGO}$-HrBjGpiaSsr9pbGMS|%kHtUzY^XqDB<I{8g1}4yK&{{ zg^3`+5%&)rRy+vzH1w*xxGhKK+tw;I`*-U=sV!VCjmJ;wyFfUp5T4;D7rE zn(_D+T>kC6nt7VWOIn|=R@9vLJveybU^!lcYFVSG_{Vm%jDyAW1B*0o2Hz8jvkSh? zN$f9gUDOTJlhQx@lBGi7tnAW<<6xkd}!y4=aqx}T>tMDDZqY&NKzkM303 z^K4nQ+_2bM>7W2-(88I{ZGy=kxGd=V)yk>!t#PFT6<97e>=1pdoOZK9a^+VMjcO)0 zxLK{-5L&I10}MP%YFTRNo$gf2HsDCwclOG%0s^*4GJsy7d}Mr=LhPy7%jYV z;T8+@a-yf0H8@0umYzAa2a#FN*U2_s`#Kg>cIj?f@LJGEODY$_ zGs%bBS*`NxN?Jed%lJUwGUL?& z16}Ic2l+WdAa(Y*raSeq8O`r!gWM;+_)RX2JFCW?(ryMwt~|;L-Ez&^SLkH9K5Rdm z{)%>%|AnMP@JCI7<@PArJ24Us_Zr566l%SM}fBIScUJgHm(*R$zM^(qo!XPIf) z*)VS>{SmeAh*TZBlA*y;ru5WOBJSZE!iAk=Q`1gm^L|%(*Q5=0?{LWLLfR2Qr$59X z?tzCRX9aU5vR^NkeC=JO9^pM3+G}M>9qFvh)&8D879W-2vq{TvWXya(T%zpHK{3Si zbmtA%y!B6YZ!I`|HlH=rpFdQYP4~RSY*)puWNYX-KV>cLNF`i*xvN*{=dW{X*(xtW z?5Y&Rj~3A;lET*V<0gc7r=OHH=QxZeWz*;X83zj-$zE1C@_uOMXg1BW7Zzw_Phk>6 z5>HR!B|gvE_B}#)r-#~jsJW{JF?Fx0ruvfaKR=~q8!x(6(u>_0t9MdO*Zg}w_4tZw zvFISQoUt**b8GpDvcVf4A^Jw0fk*PzM_5~fcLvjFWy_1Kr7-2Q&1zJta~CXMhi%<> zH=UM7D*>(s9S~y-I^Ee>>3!C*fk8@f-pts*!bndc&p8&Ctj^c8>>%mOZ2|pk=#<8> zEFN1wayu<&uAr1^s1@goz4(&p+Os&muK8wsChjHuPE3F9*mddHKnL?qzyW zdG#{-PXnm_T1Vb{y>_5>a?ND1(IuabZBW*{=$N~k>at>yq!B^mbvYLbYv>oxb*u!= z8&R{HV!tgI4>Yywf;hZg#9zJXp}*J2hjyIg+Q;-uZO24h3g#1OH5WK_$BLXb^hK@( z&$+~CDo(7Vx6sPQ&xH#n^yfN=C%qC6h{+1xH-mrH|Mb#mR-JhOwG$_%Ib+!&OO;x~ zFJ@mvlqwbt5bGtH3pdc)#yev&o2 z9h2rU&*$}q3O08J))aML=g&;K8yPT<+Qn#2JFVo{QUmF+)(0ZP4d#dQi|I%8I;V%) zbIMwy4%eESUi%4uAISY|hngzQct!Ag>YU`mD(;!-uq3MIy!9VdRazN~X%b0#H2bij z`QG)mC@o~qOrS*ob!1&)Qo&PnXVvvamY+q3KdqYtr-)Dk*_H-1v?6GH>jm>6;rdIH z>o<^1@4og{CL7vY!RNS}sD(9Pw z%;D6pQz!jI8QH8|qI=F+c#>I^A8uT8;yyB}mbWfur4sqr*18XT zm%8t;dAnivtnDS=G!J^JrrwB4k%zEu;H09Dx?@~cHRqb-o5EA-iz8ufl5eg(dz@Pz zwsG`6`+4=ccT;IjERwm2w+F8K9zSbL+FAMeh`uqtG+;({=bLup_&+0D@vJ>T$3&Zq zZUhGQ2(q4i*!1l&p|E=&m$k@~wK$b_IqMNYIV)-;-gRN=hxja0`(^7d11#oz7Zj8d zB!SMu$q=J1^#qC%!;0{yqHpF3=>G_@7CHU-Tq}U2UT1Jcl}B4;f^_w`}JHMhdM`8`JZhOsP_GAG1inwcxD>2o zZAp21oL29~#UaEsuy?m!HpgOBFvpfrS)R!&?Bwk~V=gWJtm2BUi|A>MRFE(UCr`f} zezeKMyyg%8pYs?UR>wG&%D$uLFB)*>Qn#*lJMay1QMP~f^Xr%V!J1Tm?Ay)KtK#+S z^2r%2iqnN(xP4yx9F*3?f#*~qPIwnR2{rxxeA6?KA6NLbkvVS!~B?+x%{?p+f^si3;!2ka4 ze;vX=P#pWW>7ds4ZwRVXfY8Hu#?t6X8IVGP@-V!`AR~iTub?~&hgd*4IvE8$uK0UN zhLg>IE*bkY)`r3_dTxzY>G8@?h=gM;w3LhGv=u$aLC+}BDmz-YgM$n7Kp#fIO9)Wc zLyw6V*8HF_3x~u|BZiGK0Q%m540?bJ z^)~G@D4p0 z!JwRgswyl3)p zXC8Le&eqb5f8kF>(EnPl>CQgVuF5^jinm_L3Pe=K`GhF`kM_PYs*Yvr77Om~4#6cj z!8N!9cXx;21a}RAjk`l|_u%dp+}$C#y`6KN>_^5xAq{uxpR>Z$2(07dQZI>t0qjp?H0D-g z{j*M&m04LwxU*#YH|d|uMIr9~UsI~10e6pF-*7|L^T!Q_Dx(wq-bvaEF1L^C3!x8ZIkFW(Vsn ze7a;S3AKBfCT$S1IAVw%)yzt|>^Zh?mg4?VS=>$NBQUiVcW(dz^-O=v_>X~axox+8 zE->J4V`}iZ#sbn2C&1T1z#3z~>HB89W$kY)?CC{BqO8PhGG71=QoaMjQx3yKrEiwo z<_hCx@7a7pthL&+_$<9}LHZ0ECj+D~HH&ALg6g|X1O{?MU8;3xQf?!jFY2C6N;j3^ zTOcW-{EzFW#)s4aKaYnM;a;khD65?X5BO15j?+(YX4gZbsmvp1>OvRX*} z#C?}%GzM!sYhlw`A5c(eK-q+P_hG9?13H=-?}Lvs13D*|0ZI-vzLeH`%s79-9cEuR zsBWfgE$3#PB69HZ4fn(7I?g3QCbBfA$UBvU4e;B|0>xhurXxgdk_wwL`s+=Q~W}(QlH1Hy)up6NQ{m8%twD z7uB-_Jx{hZh0g~S9@$;(qX4a*zN5*)VsuYN$&o{^GvI2Ht2jKJU;DLTMA*=)Q3DCw zEFj*((W8YSalM^m-cWJpmcVFSbEv=jxwj5S$Ur&Pn_KJbv?&b6XpiSu?7W=Oh+lZ5 z=*EYdcB;zmR_EB<9`34=8^4(_l;AY?Llv4#f{E#Zmoj+N0GMueOv7NfP~rTp8yUPB zmL9(P9!(?A*R+|1f!{Txj()#`KQR4Xc#9vtM&(nPnxAWWcBfc7CMexVm*G^eT#rk9 zdFCSvxN$8Wopec21PQBIEiHR6E~1QG(co=`)-IW&*VU~ngQl9VC;5^zOs-HDQ&`SD zMv|rKOhT88iHVTMmk{`@;Vw!D98<77x1i7`?~)1nDRM{sMc3`7lQ+#&+HpSJ*iEMA z%7Di@ravG>f)p*tmxqVFneoTm$aEeguW^_)?p1H_S*;`~UZbyw30&$RuWFejn;~|6 zYSH67IZ>C|!6wZ|!3}YGQ#wG<=Y(NY76$hiC>UuZibJGI0eEEmLA9|aUlHp1SXEWa z#}5N+%h6*F_q2ybtZCVm{reIr4s;3z1YU9#7UPZNUg*-Y9U;T>1XKvhr3rDMZxh37 z+1@4^`@OH-5Npz~5#2a6W_;5Im{uK znkdZ<9j@+m6T=33b- z)x%j2f1UaTGeknJH4%T>V~2XhLk`$l#d^)cX)V$A3A$<8^trO>#mLWjPiE$pjTbXF zgDuH(C;vU~@B&AliYx>ONDcho@~S!-J2{!#m^%Kt`PHae+b*&rKlACmbd2wERiO_^ z1ab$aiO7Vx(jUM-NQcIW1~7t2%-aV)-4IYuI3#}y(S!h{JJ3mgPe`;veag?{z`lT- zSA5Mk*Ovq&DW)esX#4#xw~D6Oo8+gn1N#$fx!Ay!c?9AIhwkFAhTD)H|du zk2htcUpSO(mn&x^3_(R(#QE;5c$P%`Aj`TpZwujFO=d*0Hntq)RA%s(>P%(QLz3Q9 zfTV>w&SC68aW=(1(TZ>@rOmDb#Sj2ZU#nXl1(NGSmgRz3bgwriLR>A>{G%Bf-e}7* zdvc3Gvu4rR$|l>sda&3)tQi7ScamI87>V5lycy90gu#ubll&T?%+dkV78DwQes=)E3xh%*^xp8XH}wF02G|gL}Vjgc_g%S}&nO z>km(C(nJ!`2&w+gkJ#6`P9;Rx@)C8|Um{S${s zrnaPhoY4y65Px1{0DI9I<_%$)Ibwl-v=sG{y~F5!?uI=DckGJ? z0aVDK?8K$Tu=3%kFZi*#6ShVSX9X&Fupwo&&q9#=l0MLQcPw^WpXXvyiKm2;R;Uu+ zho(-LNW{k;sOYWrro72f7Db21{9a2j9!%5t=~EjCbXV%wHA%^El6p=z=fY0~Zm;m}W*Wik6b;zi1#evz!L0C5e_-iW%JR@v!^+i$WMKlNfD8l z^ib44p}KKc#-u%^$ZRAm)bQoEjVqawPq#K%J6q0c7mgAc1%#aUiORjpR{;Ey4`lqD?B!99XZiZkK5O7_Aoig zHg)67j1%J~%8AjGKXmP9lXq3M)S|KNZ=I9!@ynEN%BD3nnrI>UI=)C2znO%%;XX}v zE_>;AOU;le$J*oxyR?rT9qS9He$*=1M}OWhvj22t>n-urj)(OEe8J$ajDo+tZ`rXZ zFk|3MKA;E$1Q@vguZ)6@PHtAle{?-Z+KUd0VyIrL#ZORi>CQw}p`@Y%WmMEsbp~?_ zQHBEpm&n**Ap$;dK7jc0)kaY4DrRE$aRc-@KndN(q$#B1J!?K*J<;d;I%&Uh9>xti zUVdy|er{-2ToZ-Vm2@J{#zuE%XJm8=DKdZ&H9x=WT2F@@|MMjg|57{MU~mAIFUW`R zgrT!XJq18XLU6EX!c6&!8*jTl+gZS5GHa zUM40tqBtwQXyIpWxH+kGz~D2}Zm7WUqyb8K80>Ky(t;O2yo3kNO(DczhZ5IzV524o zM+Rj49pdG;{+(?{d!~GGSjoibsOfU!Ns!bvkSNgatXr#g*rt%1@@fm5_5oc$jhJ!9r%KLN@JZknY5jHPu9w#NhC?dM2`Jj43oL3@Sr8_bgMI&}Hy>80}6tVj)_nNddEM z$FWX!P>5kC!=c&?Fr86CM<2fSOhMbp*@p6j5y@aU@w}*NjzSbdpNg?vLTXZqhjk%- z2&kYcox!q4C&C}ZJatVE8_`X|zBOzuzOB|#C~u5QTTn}=_*tjXH)YDN`y=gRNt@P? zGRwsD9q3SocDrIw&cG0PoAEX`mc29sH3Y%Q#hxNQ|BFhQI;jgED|pU}Mpx~Ej$*4; zC6h{;!DVcCA$v0603mrLe0JYk#^S&+%K-|_gd}qj(7krHBN9Dni1#S>WuG80HSZo< z&)5jH6AZfUd@ZEDJzkuABs6UdsoI5NR7x?}X4CDp-K5$msx@p1A$xxCE6{}{WIOb3 zRgAJy37F@`XzwfdxGgGy(t3w^5g*ZPU^z%$K$Is)dZW`PvpM+Kj`5>^eAc9WO*-Av zZ-PDGfDd*xJc#naV$VnyLFQ&=e7_Bsdr?&wc83`+Z0bX* z`1ErHj!zR@UzQkal(3ZZy0UAr z+1@3C?9t981NGMe6u5x$7oUq*6q;k@ZuCTM*E?y}@5OSgD)8ZbQRP%*fpu9B}l z0M4~f5YemXoA=HEm&a{#lWRZ__4{7GiEOw3git=f#Uli@=;f{@F4Qn)#NW-6_G5Yn z!S23lx)Cj51r@4mfDadzM3SM1rziKWWBMiQR$P<}6b(xUu{NVAL|daly^$JEWJqwi z5IGENt#J6G8;K|?8s}APpc{LOB@S6Q(&?tbt(rv4nn4)WH|M=^+sPmoOE2O z#Py_KE7)qO3J6~W$#ALt#aQ(qBh8j-ycHu;fIB#VJ8U4WZyT1ojkASXcn`6!%9ph% zlUjn_H4x36TNf8s`G1ifVZH5ka89Y7A&pT!c2;{xtUT5-nh>@VwwEpWQn+(9C~8Fi zUUsev$_YGDQj3l7ON#{$sqc(y7sa)p^w({OV|p1r$JX@t(ro1qts)*Hv9;;rh^lzB z!5(U4J|&!t{YK-6QfnPV+fFKz5-4}cI*6xg{%l$*iwdDN9+0t_2^W5}GT5IVDmEHF z44pudX&Xz#E?~yo${H~ll(_X`1v4i7`hl(>qGfJxJQymBylvdvn zGDxY|d-etI89O>`z9aDeK;SI)Z`zkPJeJ?bV*Hgl$Vp0SP zTFo-_MQE4l1H=1V9tBC345p^1^jXaP{%zBsp|8SS5?Ia!%Nwz6Q`m^XvEFiUzKn1Q zre*40zuXHhxY!esuK>@+K?eXovS6FUEj<}kj75!{8+(FWr%_Q`XbOnpY$00bKwVG{ z>75H7*>vX>nO^F;T80Sa)HV?~F=^u_UT9m^y#+ zxpy#!BjRmyWJ*$Z(6)q})Q=sfdSe!;l?t9?bC^Y*L-Qi1CjI%m;5oW>%TEDQ3@=5F zJi(V~&Tb0UOAA&BWHSJ32G@mcQ9MN$oxrRX4UqZ$0-Sm;{_}=oW*Ubay{#>={Zd*E zk%aGjm*tf_)rNlg7UgXoVBKD9p(hwz-rA;3HtqHuMK8kIr`_Gu^seJ!EGORU7G#4B zeU&Z^_QF4--rIz%fc7Iy(4umUD3A<9PQGVvByoqL=78YV=wJb3&=B;{s`0z#n}E%} z6)6F&2H_An-S@!*qgR6V#XRsy^1}n8P|`c0Pzv$GqzMp!!w||0gng^WnI+_rDi3_% zmnR7Weql8Vk^cqx>zD(sdY5Dm7>sP-;UwcifyicA#djMZ|2G&ciM-I89UY-SDzhvh z=o)zv2#vyf1!3?1AKZb#`u}@Lq4vX@e&+^LU zOKMLUq7D63&6!snU6q3z_rBfLEy~_0>nGBxK4)ad|(UE*-c~U#-(4R)o zedO*n{E`QAhqkSyVzy+Oc$VuRsz)VMf7a4>nA=TaIK?xRq$qapLu0!H;taWNF`b6Z z0=V!u%CN0*I#Eu-h5;n_Yh|uhIO!1YIBhjEe&QFHdwEj}>^X?TtxrdeZv$dVXz+#g ze^QJ@jwkSP2qk z()B$g50_N0=o)F%27<8|G=hHyc`(HSdRv^0CtklO2wJU}1PpNC`_|~~--=R~{#|4d zvL?#rb6$WetECfEPBwK{|{6QK7t)m2v?OjX~$1#Ik9B!d3I-iN#Q(+vH5}+Q` zfL3K30yhfC60b8-_b`#d^fRr4Xe{RJGnQgEw+U!FYDBr8+!GTx-w)_OI}8$HISRVS z!A+pARXAo;;s66!Xw_}y0=GXYMh*eEi+YWvY>MsY74c>otcoHnUe=DpfdBM=D%Pr- z7Q6b6R)yA^0pyvMU}Mm9_z2`#C;9szy4Kt@T;cAUzPm3@Q<1Xej)!T z{NrcvL4QqL!;qho0wjPgKWwa287Ne(GroO{-D&BME$4wXL%QNUph7z3;Pa-aoGF0r zdCtRTxr1~%w9os{4mt}`Ot30?Wm=qDc_g9NMrfi^f|xQ?+azk-kW+-aq(w^zdb_i! zQXGhbCVmx;k1*~^qZ{#~N{3sdw#{P4p~IoI)_%rD+6kjdiK_rj~HHftz&{NeVeeXZW9> zPcT2&iI~*sB9Wvs8jz%|i0qntVtaJ_I|tzIY!^wkR1{rAs?B+JJav@9WQK6C|Dmecom*qn9D>eRG)}42EDy*y=9oiJ=eEZD}=gU z!rQ4|W{~;wH}-Lmdi1KtTN0NQ#94^cidT_y`O0gK;iAf!xLjF#Dz$l<9<&E7Y{WB^ zYwN2)3gJ3s#Sf3ox9KL7!ii25_swo5r4G`?Ruh9z7a84RJ@wpAt+rR#)*Y#_DnB{p zuJkq5dgb%euh)#tutC55zHVar)1W?MFIuMeejVGiRT*P?LbK|^Qaxt%GrBUIi?g3LtR0c=6|q#>!KOj_Bg`u>Bd z>xXUBfeMCqP1uBJ%vJ~4KjB}L-+-i)lJp3ZYc`GU8dwT-1E^*A2-9LwQkvON26TH&Ss?WOq*wq!+S2JD@sk_lb7m zpup_V-PVuyknKJ+Q&03@V^J`MP%)Fehd)T9U<{U zzLrIvcuIBH$X*&gjR7zOd{WpqveGu0K`wBBo}*)CJeniBfjBufi~2xtRc)bm;;Iwv z#gb4qo0@o8%G*49xF<5t^y7d+c*d+Td66sKM5Wn#bb(FFyTl_U(;Yl8mbOBcIRy+| za(Lgp$h#Y+<#^D_a|@Nj85695C<(%-J@gckg5e?<)@dqqDV6^`bd&7kY?<_vjIXuH zPYqZZnp_4!!jyrC-T9fe6q)05aW(b9rT7wOU-tJqfUjhC8TMs~FM;DZgU4osJV5cYr3)qs5Sd3wJ|SPlx0bbh^3PWC2^xyp^Lz!D^dH%M&YK#_<>a@48Bqy`p6 zDORd38|OV!sFGvcg_xbWNg2E z#^8Sj4mkXCM1peV{(~)Wtb!gmB#Qpmh=hx=!N#jQ=Mt?N|m>SPtsE7Dy zfwV>UTg&tXl!{pf-7F$qWS2Dcx7Af0pHp^ix;rxa4i41Fn*mPRqW*P( zOn`!xO8N#?#=i!vm%a|!EdJ6kd~=Tkc2h}phaw(=Un2wWG$s1Q6GTP<6NL`~(OTVG zH`ZOm7pu1E8;DfS`*i=L_tTqGqG5Z(@OGsRadb>!iP=qS2Z=fLb5F~dqI#h;A>(1& zg~mSV&bsY9?CM4Ff&DYI{p2GG#@1;)%;RAaU~t+j+p&4~r8Nl_1rdJCgU zMyamtOI{%6&v4v{Fy(?SU&hlx61xFjkq-5t90YHW;Szf>9i@4YIvDGcGv!NHILz*9 zauP_`6rD&D{{0z}E}y=jez*mp%@2hm%TNjo-vgFP^vjSEn9BEhLQ^-?Uq@K1dBHUI zU8u~jFAHl(pw>T}D2biuiGm)T+VDT42sy(v)#o8eu7mF`l)X6QP<|BQ9E4c{(m2`bROsZ~5J*43c_ z&>9;BdlJ8G8VD)tDnoePdeH&n)w`^wy4mV5Kz!emyjKqp2^6%X|63p4jA8gmt@ z;sF>f^G}n3`;k=ot4$vR%?1T1jssN0k$14Qb7VBMb@;<#fC7vEtB3<^OA)c!a{f%{ zL8mg0NVzxidP%robPCn935lFJ=Vg{-Y6SE$i;?H2^h}A}+O95q>6h0(t*3e<;#N>< z*5ile(F6LyC1`t%zhvE9O?nHYSBkt-R6%_=?IRfecuko{lNBGNs!cr(@IOzDWrb13 zSx_%QjX+rTC8o3sa2m1BdsE3U^F}4$L;{o(vFknEcT_RDj?&C>F;ORgkyD%!l(sWi zgaMVHPYtBFU;yK{H}u0ZzGau;=u7OdW!b`xxpG0#dfIb>yod7j-U0lojJC_lUJRP$ zRGB=+td&cEn1Wl>rha_(RRL>VLNu(82;C~&93EajT;HJRahnv@N1bLB(T+rJ{z`Hn|+tzI`K}&y_YsYq9;8tq~ zw9jiRv~12j|B-pZ-~jhuU#|&#?R|pu*MUV%WOifze#hdc%d30I)*Glp?w4g;W?s`>`n8;Q zp?h?&%HTGAoJI+bA7&?KV@C77ywJhn4D04<;wxb=u7|CN&@+>CvQXnRc#2w~HfsJX z?*+vKn$Oz_1N??cAAh(oI~c2PdQ+jqM%iyJF5&c8pyyWb=tZ+fE* z_HS5_JYe_zH{ILUz}FI>zo9)qXW$=#ps(Tosgd>@3<9!=@F)C#P)z$5)9++cuZ6IF zbA13#X8l)Ltk)E;^Hu$(*kbx8#UI(LUQ@h&74|p9u)sel{&+F=HO1>F)87;|3jd_| zBj)s96u-^kbpYLO3Vh{1DPD)uy{32_g7BMyKDaF^| z*Z%%*FrEFM;8))NYlhbj>2HP+hd&wq!##bC|4;AaHyXJ12?XRHzRGL(f8I9!4$pV_ c8~o3kh`iJrU{m=u*@^@*0&H#4?!UJF4=hh?1poj5 literal 0 HcmV?d00001 diff --git a/frontend/src/tests/test-fixtures/sample.eml b/frontend/src/tests/test-fixtures/sample.eml new file mode 100644 index 000000000..99023066d --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.eml @@ -0,0 +1,105 @@ +Return-Path: +Delivered-To: recipient@example.com +Received: from mail.example.com (mail.example.com [192.168.1.1]) + by mx.example.com (Postfix) with ESMTP id 1234567890 + for ; Mon, 1 Jan 2024 12:00:00 +0000 (UTC) +Message-ID: +Date: Mon, 1 Jan 2024 12:00:00 +0000 +From: Test Sender +User-Agent: Mozilla/5.0 (compatible; Test Email Client) +MIME-Version: 1.0 +To: Test Recipient +Subject: Test Email for Convert Tool +Content-Type: multipart/alternative; + boundary="------------boundary123456789" + +This is a multi-part message in MIME format. +--------------boundary123456789 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +Test Email for Convert Tool +=========================== + +This is a test email for testing the EML to PDF conversion functionality. + +Email Details: +- From: test@example.com +- To: recipient@example.com +- Subject: Test Email for Convert Tool +- Date: January 1, 2024 + +Content Features: +- Plain text content +- HTML content (in alternative part) +- Headers and metadata +- MIME structure + +This email should convert to a PDF that includes: +1. Email headers (From, To, Subject, Date) +2. Email body content +3. Proper formatting + +Important Notes: +- This is a test email only +- Generated for Stirling PDF testing +- Contains no sensitive information +- Should preserve email formatting in PDF + +Best regards, +Test Email System + +--------------boundary123456789 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + + + + + + Test Email + + +

Test Email for Convert Tool

+ +

This is a test email for testing the EML to PDF conversion functionality.

+ +

Email Details:

+
    +
  • From: test@example.com
  • +
  • To: recipient@example.com
  • +
  • Subject: Test Email for Convert Tool
  • +
  • Date: January 1, 2024
  • +
+ +

Content Features:

+
    +
  • Plain text content
  • +
  • HTML content (this part)
  • +
  • Headers and metadata
  • +
  • MIME structure
  • +
+ +
+

This email should convert to a PDF that includes:

+
    +
  1. Email headers (From, To, Subject, Date)
  2. +
  3. Email body content
  4. +
  5. Proper formatting
  6. +
+
+ +

Important Notes:

+
    +
  • This is a test email only
  • +
  • Generated for Stirling PDF testing
  • +
  • Contains no sensitive information
  • +
  • Should preserve email formatting in PDF
  • +
+ +

Best regards,
+ Test Email System

+ + + +--------------boundary123456789-- \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.htm b/frontend/src/tests/test-fixtures/sample.htm new file mode 100644 index 000000000..83a5260a7 --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.htm @@ -0,0 +1,125 @@ + + + + + + Test HTML Document + + + +

Test HTML Document for Convert Tool

+ +

This is a test HTML file for testing the HTML to PDF conversion functionality. It contains various HTML elements to ensure proper conversion.

+ +

Text Formatting

+

This paragraph contains bold text, italic text, and inline code.

+ +
+

Important: This is a highlighted section that should be preserved in the PDF output.

+
+ +

Lists

+

Unordered List

+
+ +

Ordered List

+
    +
  1. Primary point
  2. +
  3. Secondary point
  4. +
  5. Tertiary point
  6. +
+ +

Table

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Column 1Column 2Column 3
Data AData BData C
Test 1Test 2Test 3
Sample XSample YSample Z
+ +

Code Block

+
function testFunction() {
+    console.log("This is a test function");
+    return "Hello from HTML to PDF conversion";
+}
+ +

Final Notes

+

This HTML document should convert to a well-formatted PDF that preserves:

+
    +
  • Text formatting (bold, italic)
  • +
  • Headings and hierarchy
  • +
  • Tables with proper borders
  • +
  • Lists (ordered and unordered)
  • +
  • Code formatting
  • +
  • Basic CSS styling
  • +
+ +

Generated for Stirling PDF Convert Tool testing purposes.

+ + \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.html b/frontend/src/tests/test-fixtures/sample.html new file mode 100644 index 000000000..83a5260a7 --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.html @@ -0,0 +1,125 @@ + + + + + + Test HTML Document + + + +

Test HTML Document for Convert Tool

+ +

This is a test HTML file for testing the HTML to PDF conversion functionality. It contains various HTML elements to ensure proper conversion.

+ +

Text Formatting

+

This paragraph contains bold text, italic text, and inline code.

+ +
+

Important: This is a highlighted section that should be preserved in the PDF output.

+
+ +

Lists

+

Unordered List

+
    +
  • First item
  • +
  • Second item with a link
  • +
  • Third item
  • +
+ +

Ordered List

+
    +
  1. Primary point
  2. +
  3. Secondary point
  4. +
  5. Tertiary point
  6. +
+ +

Table

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Column 1Column 2Column 3
Data AData BData C
Test 1Test 2Test 3
Sample XSample YSample Z
+ +

Code Block

+
function testFunction() {
+    console.log("This is a test function");
+    return "Hello from HTML to PDF conversion";
+}
+ +

Final Notes

+

This HTML document should convert to a well-formatted PDF that preserves:

+
    +
  • Text formatting (bold, italic)
  • +
  • Headings and hierarchy
  • +
  • Tables with proper borders
  • +
  • Lists (ordered and unordered)
  • +
  • Code formatting
  • +
  • Basic CSS styling
  • +
+ +

Generated for Stirling PDF Convert Tool testing purposes.

+ + \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.jpg b/frontend/src/tests/test-fixtures/sample.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a2dc48c279d12ce2e6462accceaec533ad6a55b1 GIT binary patch literal 17286 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 YX97d7?mvmH*8rAE^PhfjN_?#SFGu~ENdN!< literal 0 HcmV?d00001 diff --git a/frontend/src/tests/test-fixtures/sample.md b/frontend/src/tests/test-fixtures/sample.md new file mode 100644 index 000000000..fba73ad74 --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.md @@ -0,0 +1,49 @@ +# Test Document for Convert Tool + +This is a **test** markdown file for testing the markdown to PDF conversion functionality. + +## Features Being Tested + +- **Bold text** +- *Italic text* +- [Links](https://example.com) +- Lists and formatting + +### Code Block + +```javascript +console.log('Hello, world!'); +function testFunction() { + return "This is a test"; +} +``` + +### Table + +| Column 1 | Column 2 | Column 3 | +|----------|----------|----------| +| Data 1 | Data 2 | Data 3 | +| Test A | Test B | Test C | + +## Lists + +### Unordered List +- Item 1 +- Item 2 + - Nested item + - Another nested item +- Item 3 + +### Ordered List +1. First item +2. Second item +3. Third item + +## Blockquote + +> This is a blockquote for testing purposes. +> It should be properly formatted in the PDF output. + +## Conclusion + +This markdown file contains various elements to test the conversion functionality. The PDF output should preserve formatting, tables, code blocks, and other markdown elements. \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.pdf b/frontend/src/tests/test-fixtures/sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a7fb3ba0be6e2da38ba37bb5b7c376833ebe4d2f GIT binary patch literal 50161 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 ae^GT?Z{Pj)S2(StD8nc!dfq^r@&5peDzSk8 literal 0 HcmV?d00001 diff --git a/frontend/src/tests/test-fixtures/sample.png b/frontend/src/tests/test-fixtures/sample.png new file mode 100644 index 0000000000000000000000000000000000000000..b6993935ab9fefa0c9a161ed83e17ac37176e083 GIT binary patch literal 11400 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 RAV-T)l;qT9E2JUN{|}dVnYsV~ literal 0 HcmV?d00001 diff --git a/frontend/src/tests/test-fixtures/sample.pptx b/frontend/src/tests/test-fixtures/sample.pptx new file mode 100644 index 000000000..2067ee215 --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.pptx @@ -0,0 +1,12 @@ +# Test PPTX Presentation + +## Slide 1: Title +This is a test PowerPoint presentation for conversion testing. + +## Slide 2: Content +- Test bullet point 1 +- Test bullet point 2 +- Test bullet point 3 + +## Slide 3: Conclusion +This file should be sufficient for testing presentation conversions. \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.svg b/frontend/src/tests/test-fixtures/sample.svg new file mode 100644 index 000000000..c2056280a --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.svg @@ -0,0 +1,32 @@ + + + + + + + Test Image for Convert Tool + + + + + + + + + Circle + Square + Triangle + + + + This image tests conversion functionality + + + PNG/JPG ↔ PDF conversions + + + + + Generated for Stirling PDF testing - 400x300px + + \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.txt b/frontend/src/tests/test-fixtures/sample.txt new file mode 100644 index 000000000..903e18f09 --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.txt @@ -0,0 +1,8 @@ +This is a test text file for conversion testing. + +It contains multiple lines of text to test various conversion scenarios. +Special characters: àáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ +Numbers: 1234567890 +Symbols: !@#$%^&*()_+-=[]{}|;':\",./<>? + +This file should be sufficient for testing text-based conversions. \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.xlsx b/frontend/src/tests/test-fixtures/sample.xlsx new file mode 100644 index 000000000..7eb45724b --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.xlsx @@ -0,0 +1,6 @@ +Name,Age,City,Country,Department,Salary +John Doe,30,New York,USA,Engineering,75000 +Jane Smith,25,London,UK,Marketing,65000 +Bob Johnson,35,Toronto,Canada,Sales,70000 +Alice Brown,28,Sydney,Australia,Design,68000 +Charlie Wilson,42,Berlin,Germany,Operations,72000 \ No newline at end of file diff --git a/frontend/src/tests/test-fixtures/sample.xml b/frontend/src/tests/test-fixtures/sample.xml new file mode 100644 index 000000000..f39b92f6f --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.xml @@ -0,0 +1,18 @@ + + + Test Document + +
+ Introduction + This is a test XML document for conversion testing. +
+
+ Data + + + + + +
+
+
\ No newline at end of file diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx new file mode 100644 index 000000000..3512ca8eb --- /dev/null +++ b/frontend/src/tools/Convert.tsx @@ -0,0 +1,207 @@ +import React, { useEffect, useMemo, useRef } from "react"; +import { Button, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import DownloadIcon from "@mui/icons-material/Download"; +import { useEndpointEnabled } from "../hooks/useEndpointConfig"; +import { useFileContext } from "../contexts/FileContext"; +import { useToolFileSelection } from "../contexts/FileSelectionContext"; + +import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; +import OperationButton from "../components/tools/shared/OperationButton"; +import ErrorNotification from "../components/tools/shared/ErrorNotification"; +import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator"; +import ResultsPreview from "../components/tools/shared/ResultsPreview"; + +import ConvertSettings from "../components/tools/convert/ConvertSettings"; + +import { useConvertParameters } from "../hooks/tools/convert/useConvertParameters"; +import { useConvertOperation } from "../hooks/tools/convert/useConvertOperation"; +import { BaseToolProps } from "../types/tool"; + +const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { setCurrentMode, activeFiles } = useFileContext(); + const { selectedFiles } = useToolFileSelection(); + const scrollContainerRef = useRef(null); + + const convertParams = useConvertParameters(); + const convertOperation = useConvertOperation(); + + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled( + convertParams.getEndpointName() + ); + + const scrollToBottom = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + top: scrollContainerRef.current.scrollHeight, + behavior: 'smooth' + }); + } + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = convertOperation.downloadUrl !== null; + const filesCollapsed = hasFiles; + const settingsCollapsed = hasResults; + + useEffect(() => { + if (selectedFiles.length > 0) { + convertParams.analyzeFileTypes(selectedFiles); + } else { + // Only reset when there are no active files at all + // If there are active files but no selected files, keep current format (user filtered by format) + if (activeFiles.length === 0) { + convertParams.resetParameters(); + } + } + }, [selectedFiles, activeFiles]); + + useEffect(() => { + // Only clear results if we're not currently processing and parameters changed + if (!convertOperation.isLoading) { + convertOperation.resetResults(); + onPreviewFile?.(null); + } + }, [convertParams.parameters.fromExtension, convertParams.parameters.toExtension]); + + useEffect(() => { + if (hasFiles) { + setTimeout(scrollToBottom, 100); + } + }, [hasFiles]); + + useEffect(() => { + if (hasResults) { + setTimeout(scrollToBottom, 100); + } + }, [hasResults]); + + const handleConvert = async () => { + try { + await convertOperation.executeOperation( + convertParams.parameters, + selectedFiles + ); + if (convertOperation.files && onComplete) { + onComplete(convertOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : 'Convert operation failed'); + } + } + }; + + const handleThumbnailClick = (file: File) => { + onPreviewFile?.(file); + sessionStorage.setItem('previousMode', 'convert'); + setCurrentMode('viewer'); + }; + + const handleSettingsReset = () => { + convertOperation.resetResults(); + onPreviewFile?.(null); + setCurrentMode('convert'); + }; + + const previewResults = useMemo(() => + convertOperation.files?.map((file, index) => ({ + file, + thumbnail: convertOperation.thumbnails[index] + })) || [], + [convertOperation.files, convertOperation.thumbnails] + ); + + return ( +
+ + + + + + + + + + + {hasFiles && convertParams.parameters.fromExtension && convertParams.parameters.toExtension && ( + + )} + + + + + + {convertOperation.status && ( + {convertOperation.status} + )} + + + + {convertOperation.downloadUrl && ( + + )} + + + + + + +
+ ); +}; + +export default Convert; diff --git a/frontend/src/utils/convertUtils.test.ts b/frontend/src/utils/convertUtils.test.ts new file mode 100644 index 000000000..4f44f949b --- /dev/null +++ b/frontend/src/utils/convertUtils.test.ts @@ -0,0 +1,334 @@ +/** + * Unit tests for convertUtils + */ + +import { describe, test, expect } from 'vitest'; +import { + getEndpointName, + getEndpointUrl, + isConversionSupported, + isImageFormat +} from './convertUtils'; + +describe('convertUtils', () => { + + describe('getEndpointName', () => { + + test('should return correct endpoint names for all supported conversions', () => { + // PDF to Image formats + expect(getEndpointName('pdf', 'png')).toBe('pdf-to-img'); + expect(getEndpointName('pdf', 'jpg')).toBe('pdf-to-img'); + expect(getEndpointName('pdf', 'gif')).toBe('pdf-to-img'); + expect(getEndpointName('pdf', 'tiff')).toBe('pdf-to-img'); + expect(getEndpointName('pdf', 'bmp')).toBe('pdf-to-img'); + expect(getEndpointName('pdf', 'webp')).toBe('pdf-to-img'); + + // PDF to Office formats + expect(getEndpointName('pdf', 'docx')).toBe('pdf-to-word'); + expect(getEndpointName('pdf', 'odt')).toBe('pdf-to-word'); + expect(getEndpointName('pdf', 'pptx')).toBe('pdf-to-presentation'); + expect(getEndpointName('pdf', 'odp')).toBe('pdf-to-presentation'); + + // PDF to Data formats + expect(getEndpointName('pdf', 'csv')).toBe('pdf-to-csv'); + expect(getEndpointName('pdf', 'txt')).toBe('pdf-to-text'); + expect(getEndpointName('pdf', 'rtf')).toBe('pdf-to-text'); + expect(getEndpointName('pdf', 'md')).toBe('pdf-to-markdown'); + + // PDF to Web formats + expect(getEndpointName('pdf', 'html')).toBe('pdf-to-html'); + expect(getEndpointName('pdf', 'xml')).toBe('pdf-to-xml'); + + // PDF to PDF/A + expect(getEndpointName('pdf', 'pdfa')).toBe('pdf-to-pdfa'); + + // Office Documents to PDF + expect(getEndpointName('docx', 'pdf')).toBe('file-to-pdf'); + expect(getEndpointName('doc', 'pdf')).toBe('file-to-pdf'); + expect(getEndpointName('odt', 'pdf')).toBe('file-to-pdf'); + + // Spreadsheets to PDF + expect(getEndpointName('xlsx', 'pdf')).toBe('file-to-pdf'); + expect(getEndpointName('xls', 'pdf')).toBe('file-to-pdf'); + expect(getEndpointName('ods', 'pdf')).toBe('file-to-pdf'); + + // Presentations to PDF + expect(getEndpointName('pptx', 'pdf')).toBe('file-to-pdf'); + expect(getEndpointName('ppt', 'pdf')).toBe('file-to-pdf'); + expect(getEndpointName('odp', 'pdf')).toBe('file-to-pdf'); + + // Images to PDF + expect(getEndpointName('jpg', 'pdf')).toBe('img-to-pdf'); + expect(getEndpointName('jpeg', 'pdf')).toBe('img-to-pdf'); + expect(getEndpointName('png', 'pdf')).toBe('img-to-pdf'); + expect(getEndpointName('gif', 'pdf')).toBe('img-to-pdf'); + expect(getEndpointName('bmp', 'pdf')).toBe('img-to-pdf'); + expect(getEndpointName('tiff', 'pdf')).toBe('img-to-pdf'); + expect(getEndpointName('webp', 'pdf')).toBe('img-to-pdf'); + + // Web formats to PDF + expect(getEndpointName('html', 'pdf')).toBe('html-to-pdf'); + + // Markdown to PDF + expect(getEndpointName('md', 'pdf')).toBe('markdown-to-pdf'); + + // Text formats to PDF + expect(getEndpointName('txt', 'pdf')).toBe('file-to-pdf'); + expect(getEndpointName('rtf', 'pdf')).toBe('file-to-pdf'); + + // Email to PDF + expect(getEndpointName('eml', 'pdf')).toBe('eml-to-pdf'); + }); + + test('should return empty string for unsupported conversions', () => { + expect(getEndpointName('pdf', 'exe')).toBe(''); + expect(getEndpointName('wav', 'pdf')).toBe('file-to-pdf'); // Try using file to pdf as fallback + expect(getEndpointName('png', 'docx')).toBe(''); // Images can't convert to Word docs + }); + + test('should handle empty or invalid inputs', () => { + expect(getEndpointName('', '')).toBe(''); + expect(getEndpointName('pdf', '')).toBe(''); + expect(getEndpointName('', 'pdf')).toBe(''); + expect(getEndpointName('nonexistent', 'alsononexistent')).toBe(''); + }); + }); + + describe('getEndpointUrl', () => { + + test('should return correct endpoint URLs for all supported conversions', () => { + // PDF to Image formats + expect(getEndpointUrl('pdf', 'png')).toBe('/api/v1/convert/pdf/img'); + expect(getEndpointUrl('pdf', 'jpg')).toBe('/api/v1/convert/pdf/img'); + expect(getEndpointUrl('pdf', 'gif')).toBe('/api/v1/convert/pdf/img'); + expect(getEndpointUrl('pdf', 'tiff')).toBe('/api/v1/convert/pdf/img'); + expect(getEndpointUrl('pdf', 'bmp')).toBe('/api/v1/convert/pdf/img'); + expect(getEndpointUrl('pdf', 'webp')).toBe('/api/v1/convert/pdf/img'); + + // PDF to Office formats + expect(getEndpointUrl('pdf', 'docx')).toBe('/api/v1/convert/pdf/word'); + expect(getEndpointUrl('pdf', 'odt')).toBe('/api/v1/convert/pdf/word'); + expect(getEndpointUrl('pdf', 'pptx')).toBe('/api/v1/convert/pdf/presentation'); + expect(getEndpointUrl('pdf', 'odp')).toBe('/api/v1/convert/pdf/presentation'); + + // PDF to Data formats + expect(getEndpointUrl('pdf', 'csv')).toBe('/api/v1/convert/pdf/csv'); + expect(getEndpointUrl('pdf', 'txt')).toBe('/api/v1/convert/pdf/text'); + expect(getEndpointUrl('pdf', 'rtf')).toBe('/api/v1/convert/pdf/text'); + expect(getEndpointUrl('pdf', 'md')).toBe('/api/v1/convert/pdf/markdown'); + + // PDF to Web formats + expect(getEndpointUrl('pdf', 'html')).toBe('/api/v1/convert/pdf/html'); + expect(getEndpointUrl('pdf', 'xml')).toBe('/api/v1/convert/pdf/xml'); + + // PDF to PDF/A + expect(getEndpointUrl('pdf', 'pdfa')).toBe('/api/v1/convert/pdf/pdfa'); + + // Office Documents to PDF + expect(getEndpointUrl('docx', 'pdf')).toBe('/api/v1/convert/file/pdf'); + expect(getEndpointUrl('doc', 'pdf')).toBe('/api/v1/convert/file/pdf'); + expect(getEndpointUrl('odt', 'pdf')).toBe('/api/v1/convert/file/pdf'); + + // Spreadsheets to PDF + expect(getEndpointUrl('xlsx', 'pdf')).toBe('/api/v1/convert/file/pdf'); + expect(getEndpointUrl('xls', 'pdf')).toBe('/api/v1/convert/file/pdf'); + expect(getEndpointUrl('ods', 'pdf')).toBe('/api/v1/convert/file/pdf'); + + // Presentations to PDF + expect(getEndpointUrl('pptx', 'pdf')).toBe('/api/v1/convert/file/pdf'); + expect(getEndpointUrl('ppt', 'pdf')).toBe('/api/v1/convert/file/pdf'); + expect(getEndpointUrl('odp', 'pdf')).toBe('/api/v1/convert/file/pdf'); + + // Images to PDF + expect(getEndpointUrl('jpg', 'pdf')).toBe('/api/v1/convert/img/pdf'); + expect(getEndpointUrl('jpeg', 'pdf')).toBe('/api/v1/convert/img/pdf'); + expect(getEndpointUrl('png', 'pdf')).toBe('/api/v1/convert/img/pdf'); + expect(getEndpointUrl('gif', 'pdf')).toBe('/api/v1/convert/img/pdf'); + expect(getEndpointUrl('bmp', 'pdf')).toBe('/api/v1/convert/img/pdf'); + expect(getEndpointUrl('tiff', 'pdf')).toBe('/api/v1/convert/img/pdf'); + expect(getEndpointUrl('webp', 'pdf')).toBe('/api/v1/convert/img/pdf'); + + // Web formats to PDF + expect(getEndpointUrl('html', 'pdf')).toBe('/api/v1/convert/html/pdf'); + + // Markdown to PDF + expect(getEndpointUrl('md', 'pdf')).toBe('/api/v1/convert/markdown/pdf'); + + // Text formats to PDF + expect(getEndpointUrl('txt', 'pdf')).toBe('/api/v1/convert/file/pdf'); + expect(getEndpointUrl('rtf', 'pdf')).toBe('/api/v1/convert/file/pdf'); + + // Email to PDF + expect(getEndpointUrl('eml', 'pdf')).toBe('/api/v1/convert/eml/pdf'); + }); + + test('should return empty string for unsupported conversions', () => { + expect(getEndpointUrl('pdf', 'exe')).toBe(''); + expect(getEndpointUrl('wav', 'pdf')).toBe('/api/v1/convert/file/pdf'); // Try using file to pdf as fallback + expect(getEndpointUrl('invalid', 'invalid')).toBe(''); + }); + + test('should handle empty inputs', () => { + expect(getEndpointUrl('', '')).toBe(''); + expect(getEndpointUrl('pdf', '')).toBe(''); + expect(getEndpointUrl('', 'pdf')).toBe(''); + }); + }); + + describe('isConversionSupported', () => { + + test('should return true for all supported conversions', () => { + // PDF to Image formats + expect(isConversionSupported('pdf', 'png')).toBe(true); + expect(isConversionSupported('pdf', 'jpg')).toBe(true); + expect(isConversionSupported('pdf', 'gif')).toBe(true); + expect(isConversionSupported('pdf', 'tiff')).toBe(true); + expect(isConversionSupported('pdf', 'bmp')).toBe(true); + expect(isConversionSupported('pdf', 'webp')).toBe(true); + + // PDF to Office formats + expect(isConversionSupported('pdf', 'docx')).toBe(true); + expect(isConversionSupported('pdf', 'odt')).toBe(true); + expect(isConversionSupported('pdf', 'pptx')).toBe(true); + expect(isConversionSupported('pdf', 'odp')).toBe(true); + + // PDF to Data formats + expect(isConversionSupported('pdf', 'csv')).toBe(true); + expect(isConversionSupported('pdf', 'txt')).toBe(true); + expect(isConversionSupported('pdf', 'rtf')).toBe(true); + expect(isConversionSupported('pdf', 'md')).toBe(true); + + // PDF to Web formats + expect(isConversionSupported('pdf', 'html')).toBe(true); + expect(isConversionSupported('pdf', 'xml')).toBe(true); + + // PDF to PDF/A + expect(isConversionSupported('pdf', 'pdfa')).toBe(true); + + // Office Documents to PDF + expect(isConversionSupported('docx', 'pdf')).toBe(true); + expect(isConversionSupported('doc', 'pdf')).toBe(true); + expect(isConversionSupported('odt', 'pdf')).toBe(true); + + // Spreadsheets to PDF + expect(isConversionSupported('xlsx', 'pdf')).toBe(true); + expect(isConversionSupported('xls', 'pdf')).toBe(true); + expect(isConversionSupported('ods', 'pdf')).toBe(true); + + // Presentations to PDF + expect(isConversionSupported('pptx', 'pdf')).toBe(true); + expect(isConversionSupported('ppt', 'pdf')).toBe(true); + expect(isConversionSupported('odp', 'pdf')).toBe(true); + + // Images to PDF + expect(isConversionSupported('jpg', 'pdf')).toBe(true); + expect(isConversionSupported('jpeg', 'pdf')).toBe(true); + expect(isConversionSupported('png', 'pdf')).toBe(true); + expect(isConversionSupported('gif', 'pdf')).toBe(true); + expect(isConversionSupported('bmp', 'pdf')).toBe(true); + expect(isConversionSupported('tiff', 'pdf')).toBe(true); + expect(isConversionSupported('webp', 'pdf')).toBe(true); + + // Web formats to PDF + expect(isConversionSupported('html', 'pdf')).toBe(true); + expect(isConversionSupported('htm', 'pdf')).toBe(true); + + // Markdown to PDF + expect(isConversionSupported('md', 'pdf')).toBe(true); + + // Text formats to PDF + expect(isConversionSupported('txt', 'pdf')).toBe(true); + expect(isConversionSupported('rtf', 'pdf')).toBe(true); + + // Email to PDF + expect(isConversionSupported('eml', 'pdf')).toBe(true); + }); + + test('should return false for unsupported conversions', () => { + expect(isConversionSupported('pdf', 'exe')).toBe(false); + expect(isConversionSupported('wav', 'pdf')).toBe(true); // Fallback to file to pdf + expect(isConversionSupported('png', 'docx')).toBe(false); + expect(isConversionSupported('nonexistent', 'alsononexistent')).toBe(false); + }); + + test('should handle empty inputs', () => { + expect(isConversionSupported('', '')).toBe(false); + expect(isConversionSupported('pdf', '')).toBe(false); + expect(isConversionSupported('', 'pdf')).toBe(false); + }); + }); + + describe('isImageFormat', () => { + + test('should return true for image formats', () => { + expect(isImageFormat('png')).toBe(true); + expect(isImageFormat('jpg')).toBe(true); + expect(isImageFormat('jpeg')).toBe(true); + expect(isImageFormat('gif')).toBe(true); + expect(isImageFormat('tiff')).toBe(true); + expect(isImageFormat('bmp')).toBe(true); + expect(isImageFormat('webp')).toBe(true); + }); + + test('should return false for non-image formats', () => { + expect(isImageFormat('pdf')).toBe(false); + expect(isImageFormat('docx')).toBe(false); + expect(isImageFormat('txt')).toBe(false); + expect(isImageFormat('csv')).toBe(false); + expect(isImageFormat('html')).toBe(false); + expect(isImageFormat('xml')).toBe(false); + }); + + test('should handle case insensitivity', () => { + expect(isImageFormat('PNG')).toBe(true); + expect(isImageFormat('JPG')).toBe(true); + expect(isImageFormat('JPEG')).toBe(true); + expect(isImageFormat('Png')).toBe(true); + expect(isImageFormat('JpG')).toBe(true); + }); + + test('should handle empty and invalid inputs', () => { + expect(isImageFormat('')).toBe(false); + expect(isImageFormat('invalid')).toBe(false); + expect(isImageFormat('123')).toBe(false); + expect(isImageFormat('.')).toBe(false); + }); + + test('should handle mixed case and edge cases', () => { + expect(isImageFormat('webP')).toBe(true); + expect(isImageFormat('WEBP')).toBe(true); + expect(isImageFormat('tIFf')).toBe(true); + expect(isImageFormat('bMp')).toBe(true); + }); + }); + + describe('Edge Cases and Error Handling', () => { + + test('should handle null and undefined inputs gracefully', () => { + // Note: TypeScript prevents these, but test runtime behavior for robustness + // The current implementation handles these gracefully by returning falsy values + expect(getEndpointName(null as any, null as any)).toBe(''); + expect(getEndpointUrl(undefined as any, undefined as any)).toBe(''); + expect(isConversionSupported(null as any, null as any)).toBe(false); + + // isImageFormat will throw because it calls toLowerCase() on null/undefined + expect(() => isImageFormat(null as any)).toThrow(); + expect(() => isImageFormat(undefined as any)).toThrow(); + }); + + test('should handle special characters in file extensions', () => { + expect(isImageFormat('png@')).toBe(false); + expect(isImageFormat('jpg#')).toBe(false); + expect(isImageFormat('png.')).toBe(false); + expect(getEndpointName('pdf@', 'png')).toBe(''); + expect(getEndpointName('pdf', 'png#')).toBe(''); + }); + + test('should handle very long extension names', () => { + const longExtension = 'a'.repeat(100); + expect(isImageFormat(longExtension)).toBe(false); + expect(getEndpointName('pdf', longExtension)).toBe(''); + expect(getEndpointName(longExtension, 'pdf')).toBe('file-to-pdf'); // Fallback to file to pdf + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/convertUtils.ts b/frontend/src/utils/convertUtils.ts new file mode 100644 index 000000000..9c058c5ce --- /dev/null +++ b/frontend/src/utils/convertUtils.ts @@ -0,0 +1,59 @@ +import { + CONVERSION_ENDPOINTS, + ENDPOINT_NAMES, + EXTENSION_TO_ENDPOINT +} from '../constants/convertConstants'; + +/** + * Resolves the endpoint name for a given conversion + */ +export const getEndpointName = (fromExtension: string, toExtension: string): string => { + if (!fromExtension || !toExtension) return ''; + + let endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension]; + + // If no explicit mapping exists and we're converting to PDF, + // fall back to 'any' which uses file-to-pdf endpoint + if (!endpointKey && toExtension === 'pdf' && fromExtension !== 'any') { + endpointKey = EXTENSION_TO_ENDPOINT['any']?.[toExtension]; + } + + return endpointKey || ''; +}; + +/** + * Resolves the full endpoint URL for a given conversion + */ +export const getEndpointUrl = (fromExtension: string, toExtension: string): string => { + const endpointName = getEndpointName(fromExtension, toExtension); + if (!endpointName) return ''; + + // Find the endpoint URL from CONVERSION_ENDPOINTS using the endpoint name + for (const [key, endpoint] of Object.entries(CONVERSION_ENDPOINTS)) { + if (ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES] === endpointName) { + return endpoint; + } + } + return ''; +}; + +/** + * Checks if a conversion is supported + */ +export const isConversionSupported = (fromExtension: string, toExtension: string): boolean => { + return getEndpointName(fromExtension, toExtension) !== ''; +}; + +/** + * Checks if the given extension is an image format + */ +export const isImageFormat = (extension: string): boolean => { + return ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bmp', 'webp', 'svg'].includes(extension.toLowerCase()); +}; + +/** + * Checks if the given extension is a web format + */ +export const isWebFormat = (extension: string): boolean => { + return ['html', 'zip'].includes(extension.toLowerCase()); +}; \ No newline at end of file diff --git a/frontend/src/utils/fileResponseUtils.test.ts b/frontend/src/utils/fileResponseUtils.test.ts new file mode 100644 index 000000000..2f16a7c61 --- /dev/null +++ b/frontend/src/utils/fileResponseUtils.test.ts @@ -0,0 +1,147 @@ +/** + * Unit tests for file response utility functions + */ + +import { describe, test, expect } from 'vitest'; +import { getFilenameFromHeaders, createFileFromApiResponse } from './fileResponseUtils'; + +describe('fileResponseUtils', () => { + + describe('getFilenameFromHeaders', () => { + + test('should extract filename from content-disposition header', () => { + const contentDisposition = 'attachment; filename="document.pdf"'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('document.pdf'); + }); + + test('should extract filename without quotes', () => { + const contentDisposition = 'attachment; filename=document.pdf'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('document.pdf'); + }); + + test('should handle single quotes', () => { + const contentDisposition = "attachment; filename='document.pdf'"; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('document.pdf'); + }); + + test('should return null for malformed header', () => { + const contentDisposition = 'attachment; invalid=format'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe(null); + }); + + test('should return null for empty header', () => { + const filename = getFilenameFromHeaders(''); + + expect(filename).toBe(null); + }); + + test('should return null for undefined header', () => { + const filename = getFilenameFromHeaders(); + + expect(filename).toBe(null); + }); + + test('should handle complex filenames with spaces and special chars', () => { + const contentDisposition = 'attachment; filename="My Document (1).pdf"'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('My Document (1).pdf'); + }); + + test('should handle filename with extension when downloadHtml is enabled', () => { + const contentDisposition = 'attachment; filename="email_content.html"'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('email_content.html'); + }); + }); + + describe('createFileFromApiResponse', () => { + + test('should create file using header filename when available', () => { + const responseData = new Uint8Array([1, 2, 3, 4]); + const headers = { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="server_filename.pdf"' + }; + const fallbackFilename = 'fallback.pdf'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('server_filename.pdf'); + expect(file.type).toBe('application/pdf'); + expect(file.size).toBe(4); + }); + + test('should use fallback filename when no header filename', () => { + const responseData = new Uint8Array([1, 2, 3, 4]); + const headers = { + 'content-type': 'application/pdf' + }; + const fallbackFilename = 'converted_file.pdf'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('converted_file.pdf'); + expect(file.type).toBe('application/pdf'); + }); + + test('should handle HTML response when downloadHtml is enabled', () => { + const responseData = 'Test'; + const headers = { + 'content-type': 'text/html', + 'content-disposition': 'attachment; filename="email_content.html"' + }; + const fallbackFilename = 'fallback.pdf'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('email_content.html'); + expect(file.type).toBe('text/html'); + }); + + test('should handle ZIP response', () => { + const responseData = new Uint8Array([80, 75, 3, 4]); // ZIP file signature + const headers = { + 'content-type': 'application/zip', + 'content-disposition': 'attachment; filename="converted_files.zip"' + }; + const fallbackFilename = 'fallback.pdf'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('converted_files.zip'); + expect(file.type).toBe('application/zip'); + }); + + test('should use default content-type when none provided', () => { + const responseData = new Uint8Array([1, 2, 3, 4]); + const headers = {}; + const fallbackFilename = 'test.bin'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('test.bin'); + expect(file.type).toBe('application/octet-stream'); + }); + + test('should handle null/undefined headers gracefully', () => { + const responseData = new Uint8Array([1, 2, 3, 4]); + const headers = null; + const fallbackFilename = 'test.bin'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('test.bin'); + expect(file.type).toBe('application/octet-stream'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/fileResponseUtils.ts b/frontend/src/utils/fileResponseUtils.ts new file mode 100644 index 000000000..6e4422099 --- /dev/null +++ b/frontend/src/utils/fileResponseUtils.ts @@ -0,0 +1,37 @@ +/** + * Generic utility functions for handling file responses from API endpoints + */ + +/** + * Extracts filename from Content-Disposition header + * @param contentDisposition - Content-Disposition header value + * @returns Filename if found, null otherwise + */ +export const getFilenameFromHeaders = (contentDisposition: string = ''): string | null => { + const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (match && match[1]) { + return match[1].replace(/['"]/g, ''); + } + return null; +}; + +/** + * Creates a File object from API response using the filename from headers + * @param responseData - The response data (blob/arraybuffer/string) + * @param headers - Response headers object + * @param fallbackFilename - Filename to use if none provided in headers + * @returns File object + */ +export const createFileFromApiResponse = ( + responseData: any, + headers: any, + fallbackFilename: string +): File => { + const contentType = headers?.['content-type'] || 'application/octet-stream'; + const contentDisposition = headers?.['content-disposition'] || ''; + + const filename = getFilenameFromHeaders(contentDisposition) || fallbackFilename; + const blob = new Blob([responseData], { type: contentType }); + + return new File([blob], filename, { type: contentType }); +}; \ No newline at end of file diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index bff3f5b1c..b42d2f646 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -125,4 +125,51 @@ export function cleanupFileUrls(files: FileWithUrl[]): void { export function shouldUseDirectIndexedDBAccess(file: FileWithUrl): boolean { const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB return file.size > FILE_SIZE_LIMIT; +} + +/** + * Detects and normalizes file extension from filename + * @param filename - The filename to extract extension from + * @returns Normalized file extension in lowercase, empty string if no extension + */ +export function detectFileExtension(filename: string): string { + if (!filename || typeof filename !== 'string') return ''; + + const parts = filename.split('.'); + // If there's no extension (no dots or only one part), return empty string + if (parts.length <= 1) return ''; + + // Get the last part (extension) in lowercase + let extension = parts[parts.length - 1].toLowerCase(); + + // Normalize common extension variants + if (extension === 'jpeg') extension = 'jpg'; + + return extension; +} + +/** + * Gets the filename without extension + * @param filename - The filename to process + * @returns Filename without extension + */ +export function getFilenameWithoutExtension(filename: string): string { + if (!filename || typeof filename !== 'string') return ''; + + const parts = filename.split('.'); + if (parts.length <= 1) return filename; + + // Return all parts except the last one (extension) + return parts.slice(0, -1).join('.'); +} + +/** + * Creates a new filename with a different extension + * @param filename - Original filename + * @param newExtension - New extension (without dot) + * @returns New filename with the specified extension + */ +export function changeFileExtension(filename: string, newExtension: string): string { + const nameWithoutExt = getFilenameWithoutExtension(filename); + return `${nameWithoutExt}.${newExtension}`; } \ No newline at end of file diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index f0c28631a..35444035a 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -24,6 +24,11 @@ export async function generateThumbnailForFile(file: File): Promise Date: Fri, 1 Aug 2025 17:22:30 +0100 Subject: [PATCH 17/29] Update Frontend 3rd Party Licenses (#4074) Auto-generated by stirlingbot[bot] This PR updates the frontend license report based on changes to package.json dependencies. Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- frontend/src/assets/3rdPartyLicenses.json | 2006 +++++++++++++++++++++ 1 file changed, 2006 insertions(+) create mode 100644 frontend/src/assets/3rdPartyLicenses.json diff --git a/frontend/src/assets/3rdPartyLicenses.json b/frontend/src/assets/3rdPartyLicenses.json new file mode 100644 index 000000000..0235380af --- /dev/null +++ b/frontend/src/assets/3rdPartyLicenses.json @@ -0,0 +1,2006 @@ +{ + "dependencies": [ + { + "moduleName": "@adobe/css-tools", + "moduleUrl": "https://github.com/adobe/css-tools", + "moduleVersion": "4.4.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@alloc/quick-lru", + "moduleUrl": "https://github.com/sindresorhus/quick-lru", + "moduleVersion": "5.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@ampproject/remapping", + "moduleUrl": "https://github.com/ampproject/remapping", + "moduleVersion": "2.3.0", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "@babel/code-frame", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@babel/generator", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@babel/helper-module-imports", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@babel/helper-string-parser", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@babel/helper-validator-identifier", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@babel/parser", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@babel/runtime", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@babel/template", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@babel/traverse", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@babel/types", + "moduleUrl": "https://github.com/babel/babel", + "moduleVersion": "7.27.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/babel-plugin", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/babel-plugin", + "moduleVersion": "11.13.5", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/cache", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/cache", + "moduleVersion": "11.14.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/hash", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/hash", + "moduleVersion": "0.9.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/is-prop-valid", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/is-prop-valid", + "moduleVersion": "1.3.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/memoize", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/memoize", + "moduleVersion": "0.9.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/react", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/react", + "moduleVersion": "11.14.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/serialize", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/serialize", + "moduleVersion": "1.3.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/sheet", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/sheet", + "moduleVersion": "1.4.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/styled", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/styled", + "moduleVersion": "11.14.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/unitless", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/unitless", + "moduleVersion": "0.10.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/use-insertion-effect-with-fallbacks", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/use-insertion-effect-with-fallbacks", + "moduleVersion": "1.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/utils", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/utils", + "moduleVersion": "1.4.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@emotion/weak-memoize", + "moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/weak-memoize", + "moduleVersion": "0.4.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@floating-ui/core", + "moduleUrl": "https://github.com/floating-ui/floating-ui", + "moduleVersion": "1.7.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@floating-ui/dom", + "moduleUrl": "https://github.com/floating-ui/floating-ui", + "moduleVersion": "1.7.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@floating-ui/react-dom", + "moduleUrl": "https://github.com/floating-ui/floating-ui", + "moduleVersion": "2.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@floating-ui/react", + "moduleUrl": "https://github.com/floating-ui/floating-ui", + "moduleVersion": "0.26.28", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@floating-ui/utils", + "moduleUrl": "https://github.com/floating-ui/floating-ui", + "moduleVersion": "0.2.9", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@isaacs/fs-minipass", + "moduleUrl": "https://github.com/npm/fs-minipass", + "moduleVersion": "4.0.1", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "@jridgewell/gen-mapping", + "moduleUrl": "https://github.com/jridgewell/gen-mapping", + "moduleVersion": "0.3.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@jridgewell/resolve-uri", + "moduleUrl": "https://github.com/jridgewell/resolve-uri", + "moduleVersion": "3.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@jridgewell/set-array", + "moduleUrl": "https://github.com/jridgewell/set-array", + "moduleVersion": "1.2.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@jridgewell/sourcemap-codec", + "moduleUrl": "https://github.com/jridgewell/sourcemap-codec", + "moduleVersion": "1.5.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@jridgewell/trace-mapping", + "moduleUrl": "https://github.com/jridgewell/trace-mapping", + "moduleVersion": "0.3.25", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mantine/core", + "moduleUrl": "https://github.com/mantinedev/mantine", + "moduleVersion": "8.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mantine/dropzone", + "moduleUrl": "https://github.com/mantinedev/mantine", + "moduleVersion": "8.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mantine/hooks", + "moduleUrl": "https://github.com/mantinedev/mantine", + "moduleVersion": "8.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mapbox/node-pre-gyp", + "moduleUrl": "https://github.com/mapbox/node-pre-gyp", + "moduleVersion": "1.0.11", + "moduleLicense": "BSD-3-Clause", + "moduleLicenseUrl": "https://opensource.org/licenses/BSD-3-Clause" + }, + { + "moduleName": "@mui/core-downloads-tracker", + "moduleUrl": "https://github.com/mui/material-ui", + "moduleVersion": "7.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mui/icons-material", + "moduleUrl": "https://github.com/mui/material-ui", + "moduleVersion": "7.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mui/material", + "moduleUrl": "https://github.com/mui/material-ui", + "moduleVersion": "7.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mui/private-theming", + "moduleUrl": "https://github.com/mui/material-ui", + "moduleVersion": "7.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mui/styled-engine", + "moduleUrl": "https://github.com/mui/material-ui", + "moduleVersion": "7.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mui/system", + "moduleUrl": "https://github.com/mui/material-ui", + "moduleVersion": "7.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mui/types", + "moduleUrl": "https://github.com/mui/material-ui", + "moduleVersion": "7.4.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@mui/utils", + "moduleUrl": "https://github.com/mui/material-ui", + "moduleVersion": "7.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@pdf-lib/standard-fonts", + "moduleUrl": "https://github.com/Hopding/standard-fonts", + "moduleVersion": "1.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@pdf-lib/upng", + "moduleUrl": "https://github.com/Hopding/upng", + "moduleVersion": "1.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@popperjs/core", + "moduleUrl": "https://github.com/popperjs/popper-core", + "moduleVersion": "2.11.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@tailwindcss/node", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss", + "moduleVersion": "4.1.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@tailwindcss/oxide-linux-x64-gnu", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss", + "moduleVersion": "4.1.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@tailwindcss/oxide-linux-x64-musl", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss", + "moduleVersion": "4.1.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@tailwindcss/oxide", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss", + "moduleVersion": "4.1.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@tailwindcss/postcss", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss", + "moduleVersion": "4.1.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@testing-library/dom", + "moduleUrl": "https://github.com/testing-library/dom-testing-library", + "moduleVersion": "10.4.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@testing-library/jest-dom", + "moduleUrl": "https://github.com/testing-library/jest-dom", + "moduleVersion": "6.6.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@testing-library/react", + "moduleUrl": "https://github.com/testing-library/react-testing-library", + "moduleVersion": "16.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@testing-library/user-event", + "moduleUrl": "https://github.com/testing-library/user-event", + "moduleVersion": "13.5.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@types/aria-query", + "moduleUrl": "https://github.com/DefinitelyTyped/DefinitelyTyped", + "moduleVersion": "5.0.4", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@types/parse-json", + "moduleUrl": "https://github.com/DefinitelyTyped/DefinitelyTyped", + "moduleVersion": "4.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@types/prop-types", + "moduleUrl": "https://github.com/DefinitelyTyped/DefinitelyTyped", + "moduleVersion": "15.7.14", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@types/react-dom", + "moduleUrl": "https://github.com/DefinitelyTyped/DefinitelyTyped", + "moduleVersion": "19.1.5", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@types/react-transition-group", + "moduleUrl": "https://github.com/DefinitelyTyped/DefinitelyTyped", + "moduleVersion": "4.4.12", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "@types/react", + "moduleUrl": "https://github.com/DefinitelyTyped/DefinitelyTyped", + "moduleVersion": "19.1.4", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "abbrev", + "moduleUrl": "https://github.com/isaacs/abbrev-js", + "moduleVersion": "1.1.1", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "agent-base", + "moduleUrl": "https://github.com/TooTallNate/node-agent-base", + "moduleVersion": "6.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "ansi-regex", + "moduleUrl": "https://github.com/chalk/ansi-regex", + "moduleVersion": "5.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "ansi-styles", + "moduleUrl": "https://github.com/chalk/ansi-styles", + "moduleVersion": "4.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "ansi-styles", + "moduleUrl": "https://github.com/chalk/ansi-styles", + "moduleVersion": "5.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "aproba", + "moduleUrl": "https://github.com/iarna/aproba", + "moduleVersion": "2.0.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "are-we-there-yet", + "moduleUrl": "https://github.com/npm/are-we-there-yet", + "moduleVersion": "2.0.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "aria-query", + "moduleUrl": "https://github.com/A11yance/aria-query", + "moduleVersion": "5.3.0", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "asynckit", + "moduleUrl": "https://github.com/alexindigo/asynckit", + "moduleVersion": "0.4.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "attr-accept", + "moduleUrl": "https://github.com/react-dropzone/attr-accept", + "moduleVersion": "2.2.5", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "autoprefixer", + "moduleUrl": "https://github.com/postcss/autoprefixer", + "moduleVersion": "10.4.21", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "axios", + "moduleUrl": "https://github.com/axios/axios", + "moduleVersion": "1.9.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "babel-plugin-macros", + "moduleUrl": "https://github.com/kentcdodds/babel-plugin-macros", + "moduleVersion": "3.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "balanced-match", + "moduleUrl": "https://github.com/juliangruber/balanced-match", + "moduleVersion": "1.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "brace-expansion", + "moduleUrl": "https://github.com/juliangruber/brace-expansion", + "moduleVersion": "1.1.11", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "browserslist", + "moduleUrl": "https://github.com/browserslist/browserslist", + "moduleVersion": "4.24.5", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "call-bind-apply-helpers", + "moduleUrl": "https://github.com/ljharb/call-bind-apply-helpers", + "moduleVersion": "1.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "callsites", + "moduleUrl": "https://github.com/sindresorhus/callsites", + "moduleVersion": "3.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "caniuse-lite", + "moduleUrl": "https://github.com/browserslist/caniuse-lite", + "moduleVersion": "1.0.30001718", + "moduleLicense": "CC-BY-4.0", + "moduleLicenseUrl": "" + }, + { + "moduleName": "canvas", + "moduleUrl": "https://github.com/Automattic/node-canvas", + "moduleVersion": "2.11.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "chalk", + "moduleUrl": "https://github.com/chalk/chalk", + "moduleVersion": "3.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "chalk", + "moduleUrl": "https://github.com/chalk/chalk", + "moduleVersion": "4.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "chownr", + "moduleUrl": "https://github.com/isaacs/chownr", + "moduleVersion": "2.0.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "chownr", + "moduleUrl": "https://github.com/isaacs/chownr", + "moduleVersion": "3.0.0", + "moduleLicense": "BlueOak-1.0.0", + "moduleLicenseUrl": "" + }, + { + "moduleName": "clsx", + "moduleUrl": "https://github.com/lukeed/clsx", + "moduleVersion": "2.1.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "color-convert", + "moduleUrl": "https://github.com/Qix-/color-convert", + "moduleVersion": "2.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "color-name", + "moduleUrl": "https://github.com/colorjs/color-name", + "moduleVersion": "1.1.4", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "color-support", + "moduleUrl": "https://github.com/isaacs/color-support", + "moduleVersion": "1.1.3", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "combined-stream", + "moduleUrl": "https://github.com/felixge/node-combined-stream", + "moduleVersion": "1.0.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "concat-map", + "moduleUrl": "https://github.com/substack/node-concat-map", + "moduleVersion": "0.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "console-control-strings", + "moduleUrl": "https://github.com/iarna/console-control-strings", + "moduleVersion": "1.1.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "convert-source-map", + "moduleUrl": "https://github.com/thlorenz/convert-source-map", + "moduleVersion": "1.9.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "cookie", + "moduleUrl": "https://github.com/jshttp/cookie", + "moduleVersion": "1.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "core-util-is", + "moduleUrl": "https://github.com/isaacs/core-util-is", + "moduleVersion": "1.0.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "cosmiconfig", + "moduleUrl": "https://github.com/davidtheclark/cosmiconfig", + "moduleVersion": "7.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "cross-fetch", + "moduleUrl": "https://github.com/lquixada/cross-fetch", + "moduleVersion": "4.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "css.escape", + "moduleUrl": "https://github.com/mathiasbynens/CSS.escape", + "moduleVersion": "1.5.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "csstype", + "moduleUrl": "https://github.com/frenic/csstype", + "moduleVersion": "3.1.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "debug", + "moduleUrl": "https://github.com/debug-js/debug", + "moduleVersion": "4.4.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "decompress-response", + "moduleUrl": "https://github.com/sindresorhus/decompress-response", + "moduleVersion": "4.2.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "delayed-stream", + "moduleUrl": "https://github.com/felixge/node-delayed-stream", + "moduleVersion": "1.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "delegates", + "moduleUrl": "https://github.com/visionmedia/node-delegates", + "moduleVersion": "1.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "dequal", + "moduleUrl": "https://github.com/lukeed/dequal", + "moduleVersion": "2.0.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "detect-libc", + "moduleUrl": "https://github.com/lovell/detect-libc", + "moduleVersion": "2.0.4", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "detect-node-es", + "moduleUrl": "https://github.com/thekashey/detect-node", + "moduleVersion": "1.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "dom-accessibility-api", + "moduleUrl": "https://github.com/eps1lon/dom-accessibility-api", + "moduleVersion": "0.5.16", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "dom-accessibility-api", + "moduleUrl": "https://github.com/eps1lon/dom-accessibility-api", + "moduleVersion": "0.6.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "dom-helpers", + "moduleUrl": "https://github.com/react-bootstrap/dom-helpers", + "moduleVersion": "5.2.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "dunder-proto", + "moduleUrl": "https://github.com/es-shims/dunder-proto", + "moduleVersion": "1.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "electron-to-chromium", + "moduleUrl": "https://github.com/kilian/electron-to-chromium", + "moduleVersion": "1.5.159", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "emoji-regex", + "moduleUrl": "https://github.com/mathiasbynens/emoji-regex", + "moduleVersion": "8.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "enhanced-resolve", + "moduleUrl": "https://github.com/webpack/enhanced-resolve", + "moduleVersion": "5.18.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "error-ex", + "moduleUrl": "https://github.com/qix-/node-error-ex", + "moduleVersion": "1.3.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "es-define-property", + "moduleUrl": "https://github.com/ljharb/es-define-property", + "moduleVersion": "1.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "es-errors", + "moduleUrl": "https://github.com/ljharb/es-errors", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "es-object-atoms", + "moduleUrl": "https://github.com/ljharb/es-object-atoms", + "moduleVersion": "1.1.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "es-set-tostringtag", + "moduleUrl": "https://github.com/es-shims/es-set-tostringtag", + "moduleVersion": "2.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "escalade", + "moduleUrl": "https://github.com/lukeed/escalade", + "moduleVersion": "3.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "escape-string-regexp", + "moduleUrl": "https://github.com/sindresorhus/escape-string-regexp", + "moduleVersion": "4.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "file-selector", + "moduleUrl": "https://github.com/react-dropzone/file-selector", + "moduleVersion": "2.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "find-root", + "moduleUrl": "https://github.com/js-n/find-root", + "moduleVersion": "1.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "follow-redirects", + "moduleUrl": "https://github.com/follow-redirects/follow-redirects", + "moduleVersion": "1.15.9", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "form-data", + "moduleUrl": "https://github.com/form-data/form-data", + "moduleVersion": "4.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "fraction.js", + "moduleUrl": "https://github.com/rawify/Fraction.js", + "moduleVersion": "4.3.7", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "frontend", + "moduleUrl": "https://www.npmjs.com/package/frontend", + "moduleVersion": "0.1.0", + "moduleLicense": "UNLICENSED", + "moduleLicenseUrl": "" + }, + { + "moduleName": "fs-minipass", + "moduleUrl": "https://github.com/npm/fs-minipass", + "moduleVersion": "2.1.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "fs.realpath", + "moduleUrl": "https://github.com/isaacs/fs.realpath", + "moduleVersion": "1.0.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "function-bind", + "moduleUrl": "https://github.com/Raynos/function-bind", + "moduleVersion": "1.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "gauge", + "moduleUrl": "https://github.com/iarna/gauge", + "moduleVersion": "3.0.2", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "get-intrinsic", + "moduleUrl": "https://github.com/ljharb/get-intrinsic", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "get-nonce", + "moduleUrl": "https://github.com/theKashey/get-nonce", + "moduleVersion": "1.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "get-proto", + "moduleUrl": "https://github.com/ljharb/get-proto", + "moduleVersion": "1.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "glob", + "moduleUrl": "https://github.com/isaacs/node-glob", + "moduleVersion": "7.2.3", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "globals", + "moduleUrl": "https://github.com/sindresorhus/globals", + "moduleVersion": "11.12.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "gopd", + "moduleUrl": "https://github.com/ljharb/gopd", + "moduleVersion": "1.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "graceful-fs", + "moduleUrl": "https://github.com/isaacs/node-graceful-fs", + "moduleVersion": "4.2.11", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "has-flag", + "moduleUrl": "https://github.com/sindresorhus/has-flag", + "moduleVersion": "4.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "has-symbols", + "moduleUrl": "https://github.com/inspect-js/has-symbols", + "moduleVersion": "1.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "has-tostringtag", + "moduleUrl": "https://github.com/inspect-js/has-tostringtag", + "moduleVersion": "1.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "has-unicode", + "moduleUrl": "https://github.com/iarna/has-unicode", + "moduleVersion": "2.0.1", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "hasown", + "moduleUrl": "https://github.com/inspect-js/hasOwn", + "moduleVersion": "2.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "hoist-non-react-statics", + "moduleUrl": "https://github.com/mridgway/hoist-non-react-statics", + "moduleVersion": "3.3.2", + "moduleLicense": "BSD-3-Clause", + "moduleLicenseUrl": "https://opensource.org/licenses/BSD-3-Clause" + }, + { + "moduleName": "html-parse-stringify", + "moduleUrl": "https://github.com/henrikjoreteg/html-parse-stringify", + "moduleVersion": "3.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "https-proxy-agent", + "moduleUrl": "https://github.com/TooTallNate/node-https-proxy-agent", + "moduleVersion": "5.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "i18next-browser-languagedetector", + "moduleUrl": "https://github.com/i18next/i18next-browser-languageDetector", + "moduleVersion": "8.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "i18next-http-backend", + "moduleUrl": "https://github.com/i18next/i18next-http-backend", + "moduleVersion": "3.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "i18next", + "moduleUrl": "https://github.com/i18next/i18next", + "moduleVersion": "25.2.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "immediate", + "moduleUrl": "https://github.com/calvinmetcalf/immediate", + "moduleVersion": "3.0.6", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "import-fresh", + "moduleUrl": "https://github.com/sindresorhus/import-fresh", + "moduleVersion": "3.3.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "indent-string", + "moduleUrl": "https://github.com/sindresorhus/indent-string", + "moduleVersion": "4.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "inflight", + "moduleUrl": "https://github.com/npm/inflight", + "moduleVersion": "1.0.6", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "inherits", + "moduleUrl": "https://github.com/isaacs/inherits", + "moduleVersion": "2.0.4", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "is-arrayish", + "moduleUrl": "https://github.com/qix-/node-is-arrayish", + "moduleVersion": "0.2.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "is-core-module", + "moduleUrl": "https://github.com/inspect-js/is-core-module", + "moduleVersion": "2.16.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "is-fullwidth-code-point", + "moduleUrl": "https://github.com/sindresorhus/is-fullwidth-code-point", + "moduleVersion": "3.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "isarray", + "moduleUrl": "https://github.com/juliangruber/isarray", + "moduleVersion": "1.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "jiti", + "moduleUrl": "https://github.com/unjs/jiti", + "moduleVersion": "2.4.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "js-tokens", + "moduleUrl": "https://github.com/lydell/js-tokens", + "moduleVersion": "4.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "jsesc", + "moduleUrl": "https://github.com/mathiasbynens/jsesc", + "moduleVersion": "3.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "json-parse-even-better-errors", + "moduleUrl": "https://github.com/npm/json-parse-even-better-errors", + "moduleVersion": "2.3.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "jszip", + "moduleUrl": "https://github.com/Stuk/jszip", + "moduleVersion": "3.10.1", + "moduleLicense": "(MIT OR GPL-3.0-or-later)", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "lie", + "moduleUrl": "https://github.com/calvinmetcalf/lie", + "moduleVersion": "3.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "lightningcss-linux-x64-gnu", + "moduleUrl": "https://github.com/parcel-bundler/lightningcss", + "moduleVersion": "1.30.1", + "moduleLicense": "MPL-2.0", + "moduleLicenseUrl": "https://www.mozilla.org/en-US/MPL/2.0/" + }, + { + "moduleName": "lightningcss-linux-x64-musl", + "moduleUrl": "https://github.com/parcel-bundler/lightningcss", + "moduleVersion": "1.30.1", + "moduleLicense": "MPL-2.0", + "moduleLicenseUrl": "https://www.mozilla.org/en-US/MPL/2.0/" + }, + { + "moduleName": "lightningcss", + "moduleUrl": "https://github.com/parcel-bundler/lightningcss", + "moduleVersion": "1.30.1", + "moduleLicense": "MPL-2.0", + "moduleLicenseUrl": "https://www.mozilla.org/en-US/MPL/2.0/" + }, + { + "moduleName": "lines-and-columns", + "moduleUrl": "https://github.com/eventualbuddha/lines-and-columns", + "moduleVersion": "1.2.4", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "lodash", + "moduleUrl": "https://github.com/lodash/lodash", + "moduleVersion": "4.17.21", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "loose-envify", + "moduleUrl": "https://github.com/zertosh/loose-envify", + "moduleVersion": "1.4.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "lz-string", + "moduleUrl": "https://github.com/pieroxy/lz-string", + "moduleVersion": "1.5.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "magic-string", + "moduleUrl": "https://github.com/rich-harris/magic-string", + "moduleVersion": "0.30.17", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "make-dir", + "moduleUrl": "https://github.com/sindresorhus/make-dir", + "moduleVersion": "3.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "material-symbols", + "moduleUrl": "https://github.com/marella/material-symbols", + "moduleVersion": "0.33.0", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "math-intrinsics", + "moduleUrl": "https://github.com/es-shims/math-intrinsics", + "moduleVersion": "1.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "mime-db", + "moduleUrl": "https://github.com/jshttp/mime-db", + "moduleVersion": "1.52.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "mime-types", + "moduleUrl": "https://github.com/jshttp/mime-types", + "moduleVersion": "2.1.35", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "mimic-response", + "moduleUrl": "https://github.com/sindresorhus/mimic-response", + "moduleVersion": "2.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "min-indent", + "moduleUrl": "https://github.com/thejameskyle/min-indent", + "moduleVersion": "1.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "minimatch", + "moduleUrl": "https://github.com/isaacs/minimatch", + "moduleVersion": "3.1.2", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "minipass", + "moduleUrl": "https://github.com/isaacs/minipass", + "moduleVersion": "3.3.6", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "minipass", + "moduleUrl": "https://github.com/isaacs/minipass", + "moduleVersion": "5.0.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "minipass", + "moduleUrl": "https://github.com/isaacs/minipass", + "moduleVersion": "7.1.2", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "minizlib", + "moduleUrl": "https://github.com/isaacs/minizlib", + "moduleVersion": "2.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "minizlib", + "moduleUrl": "https://github.com/isaacs/minizlib", + "moduleVersion": "3.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "mkdirp", + "moduleUrl": "https://github.com/isaacs/node-mkdirp", + "moduleVersion": "1.0.4", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "mkdirp", + "moduleUrl": "https://github.com/isaacs/node-mkdirp", + "moduleVersion": "3.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "ms", + "moduleUrl": "https://github.com/vercel/ms", + "moduleVersion": "2.1.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "nan", + "moduleUrl": "https://github.com/nodejs/nan", + "moduleVersion": "2.22.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "nanoid", + "moduleUrl": "https://github.com/ai/nanoid", + "moduleVersion": "3.3.11", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "node-fetch", + "moduleUrl": "https://github.com/bitinn/node-fetch", + "moduleVersion": "2.7.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "node-releases", + "moduleUrl": "https://github.com/chicoxyzzy/node-releases", + "moduleVersion": "2.0.19", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "nopt", + "moduleUrl": "https://github.com/npm/nopt", + "moduleVersion": "5.0.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "normalize-range", + "moduleUrl": "https://github.com/jamestalmage/normalize-range", + "moduleVersion": "0.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "npmlog", + "moduleUrl": "https://github.com/npm/npmlog", + "moduleVersion": "5.0.1", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "object-assign", + "moduleUrl": "https://github.com/sindresorhus/object-assign", + "moduleVersion": "4.1.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "once", + "moduleUrl": "https://github.com/isaacs/once", + "moduleVersion": "1.4.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "pako", + "moduleUrl": "https://github.com/nodeca/pako", + "moduleVersion": "1.0.11", + "moduleLicense": "(MIT AND Zlib)", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "parent-module", + "moduleUrl": "https://github.com/sindresorhus/parent-module", + "moduleVersion": "1.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "parse-json", + "moduleUrl": "https://github.com/sindresorhus/parse-json", + "moduleVersion": "5.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "path-is-absolute", + "moduleUrl": "https://github.com/sindresorhus/path-is-absolute", + "moduleVersion": "1.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "path-parse", + "moduleUrl": "https://github.com/jbgutierrez/path-parse", + "moduleVersion": "1.0.7", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "path-type", + "moduleUrl": "https://github.com/sindresorhus/path-type", + "moduleVersion": "4.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "path2d-polyfill", + "moduleUrl": "https://github.com/nilzona/path2d-polyfill", + "moduleVersion": "2.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "pdf-lib", + "moduleUrl": "https://github.com/Hopding/pdf-lib", + "moduleVersion": "1.17.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "pdfjs-dist", + "moduleUrl": "https://github.com/mozilla/pdfjs-dist", + "moduleVersion": "3.11.174", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "picocolors", + "moduleUrl": "https://github.com/alexeyraspopov/picocolors", + "moduleVersion": "1.1.1", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "postcss-value-parser", + "moduleUrl": "https://github.com/TrySound/postcss-value-parser", + "moduleVersion": "4.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "postcss", + "moduleUrl": "https://github.com/postcss/postcss", + "moduleVersion": "8.5.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "pretty-format", + "moduleUrl": "https://github.com/facebook/jest", + "moduleVersion": "27.5.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "process-nextick-args", + "moduleUrl": "https://github.com/calvinmetcalf/process-nextick-args", + "moduleVersion": "2.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "prop-types", + "moduleUrl": "https://github.com/facebook/prop-types", + "moduleVersion": "15.8.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "proxy-from-env", + "moduleUrl": "https://github.com/Rob--W/proxy-from-env", + "moduleVersion": "1.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-dom", + "moduleUrl": "https://github.com/facebook/react", + "moduleVersion": "19.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-dropzone", + "moduleUrl": "https://github.com/react-dropzone/react-dropzone", + "moduleVersion": "14.3.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-i18next", + "moduleUrl": "https://github.com/i18next/react-i18next", + "moduleVersion": "15.5.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-is", + "moduleUrl": "https://github.com/facebook/react", + "moduleVersion": "16.13.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-is", + "moduleUrl": "https://github.com/facebook/react", + "moduleVersion": "17.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-is", + "moduleUrl": "https://github.com/facebook/react", + "moduleVersion": "19.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-number-format", + "moduleUrl": "https://github.com/s-yadav/react-number-format", + "moduleVersion": "5.4.4", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-remove-scroll-bar", + "moduleUrl": "https://github.com/theKashey/react-remove-scroll-bar", + "moduleVersion": "2.3.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-remove-scroll", + "moduleUrl": "https://github.com/theKashey/react-remove-scroll", + "moduleVersion": "2.6.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-router-dom", + "moduleUrl": "https://github.com/remix-run/react-router", + "moduleVersion": "7.6.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-router", + "moduleUrl": "https://github.com/remix-run/react-router", + "moduleVersion": "7.6.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-style-singleton", + "moduleUrl": "https://github.com/theKashey/react-style-singleton", + "moduleVersion": "2.2.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-textarea-autosize", + "moduleUrl": "https://github.com/Andarist/react-textarea-autosize", + "moduleVersion": "8.5.9", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "react-transition-group", + "moduleUrl": "https://github.com/reactjs/react-transition-group", + "moduleVersion": "4.4.5", + "moduleLicense": "BSD-3-Clause", + "moduleLicenseUrl": "https://opensource.org/licenses/BSD-3-Clause" + }, + { + "moduleName": "react", + "moduleUrl": "https://github.com/facebook/react", + "moduleVersion": "19.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "readable-stream", + "moduleUrl": "https://github.com/nodejs/readable-stream", + "moduleVersion": "2.3.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "readable-stream", + "moduleUrl": "https://github.com/nodejs/readable-stream", + "moduleVersion": "3.6.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "redent", + "moduleUrl": "https://github.com/sindresorhus/redent", + "moduleVersion": "3.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "resolve-from", + "moduleUrl": "https://github.com/sindresorhus/resolve-from", + "moduleVersion": "4.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "resolve", + "moduleUrl": "https://github.com/browserify/resolve", + "moduleVersion": "1.22.10", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "rimraf", + "moduleUrl": "https://github.com/isaacs/rimraf", + "moduleVersion": "3.0.2", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "safe-buffer", + "moduleUrl": "https://github.com/feross/safe-buffer", + "moduleVersion": "5.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "safe-buffer", + "moduleUrl": "https://github.com/feross/safe-buffer", + "moduleVersion": "5.2.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "scheduler", + "moduleUrl": "https://github.com/facebook/react", + "moduleVersion": "0.26.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "semver", + "moduleUrl": "https://github.com/npm/node-semver", + "moduleVersion": "6.3.1", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "semver", + "moduleUrl": "https://github.com/npm/node-semver", + "moduleVersion": "7.7.2", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "set-blocking", + "moduleUrl": "https://github.com/yargs/set-blocking", + "moduleVersion": "2.0.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "set-cookie-parser", + "moduleUrl": "https://github.com/nfriedly/set-cookie-parser", + "moduleVersion": "2.7.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "setimmediate", + "moduleUrl": "https://github.com/YuzuJS/setImmediate", + "moduleVersion": "1.0.5", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "signal-exit", + "moduleUrl": "https://github.com/tapjs/signal-exit", + "moduleVersion": "3.0.7", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "simple-concat", + "moduleUrl": "https://github.com/feross/simple-concat", + "moduleVersion": "1.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "simple-get", + "moduleUrl": "https://github.com/feross/simple-get", + "moduleVersion": "3.1.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "source-map-js", + "moduleUrl": "https://github.com/7rulnik/source-map-js", + "moduleVersion": "1.2.1", + "moduleLicense": "BSD-3-Clause", + "moduleLicenseUrl": "https://opensource.org/licenses/BSD-3-Clause" + }, + { + "moduleName": "source-map", + "moduleUrl": "https://github.com/mozilla/source-map", + "moduleVersion": "0.5.7", + "moduleLicense": "BSD-3-Clause", + "moduleLicenseUrl": "https://opensource.org/licenses/BSD-3-Clause" + }, + { + "moduleName": "string-width", + "moduleUrl": "https://github.com/sindresorhus/string-width", + "moduleVersion": "4.2.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "string_decoder", + "moduleUrl": "https://github.com/nodejs/string_decoder", + "moduleVersion": "1.1.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "string_decoder", + "moduleUrl": "https://github.com/nodejs/string_decoder", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "strip-ansi", + "moduleUrl": "https://github.com/chalk/strip-ansi", + "moduleVersion": "6.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "strip-indent", + "moduleUrl": "https://github.com/sindresorhus/strip-indent", + "moduleVersion": "3.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "stylis", + "moduleUrl": "https://github.com/thysultan/stylis.js", + "moduleVersion": "4.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "supports-color", + "moduleUrl": "https://github.com/chalk/supports-color", + "moduleVersion": "7.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "supports-preserve-symlinks-flag", + "moduleUrl": "https://github.com/inspect-js/node-supports-preserve-symlinks-flag", + "moduleVersion": "1.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "tabbable", + "moduleUrl": "https://github.com/focus-trap/tabbable", + "moduleVersion": "6.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "tailwindcss", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss", + "moduleVersion": "4.1.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "tapable", + "moduleUrl": "https://github.com/webpack/tapable", + "moduleVersion": "2.2.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "tar", + "moduleUrl": "https://github.com/isaacs/node-tar", + "moduleVersion": "6.2.1", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "tar", + "moduleUrl": "https://github.com/isaacs/node-tar", + "moduleVersion": "7.4.3", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "tr46", + "moduleUrl": "https://github.com/Sebmaster/tr46.js", + "moduleVersion": "0.0.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "tslib", + "moduleUrl": "https://github.com/Microsoft/tslib", + "moduleVersion": "1.14.1", + "moduleLicense": "0BSD", + "moduleLicenseUrl": "" + }, + { + "moduleName": "tslib", + "moduleUrl": "https://github.com/Microsoft/tslib", + "moduleVersion": "2.8.1", + "moduleLicense": "0BSD", + "moduleLicenseUrl": "" + }, + { + "moduleName": "type-fest", + "moduleUrl": "https://github.com/sindresorhus/type-fest", + "moduleVersion": "4.41.0", + "moduleLicense": "(MIT OR CC0-1.0)", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "typescript", + "moduleUrl": "https://github.com/microsoft/TypeScript", + "moduleVersion": "5.8.3", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "update-browserslist-db", + "moduleUrl": "https://github.com/browserslist/update-db", + "moduleVersion": "1.1.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "use-callback-ref", + "moduleUrl": "https://github.com/theKashey/use-callback-ref", + "moduleVersion": "1.3.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "use-composed-ref", + "moduleUrl": "https://github.com/Andarist/use-composed-ref", + "moduleVersion": "1.4.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "use-isomorphic-layout-effect", + "moduleUrl": "https://github.com/Andarist/use-isomorphic-layout-effect", + "moduleVersion": "1.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "use-latest", + "moduleUrl": "https://github.com/Andarist/use-latest", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "use-sidecar", + "moduleUrl": "https://github.com/theKashey/use-sidecar", + "moduleVersion": "1.1.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "util-deprecate", + "moduleUrl": "https://github.com/TooTallNate/util-deprecate", + "moduleVersion": "1.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "void-elements", + "moduleUrl": "https://github.com/pugjs/void-elements", + "moduleVersion": "3.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "web-vitals", + "moduleUrl": "https://github.com/GoogleChrome/web-vitals", + "moduleVersion": "2.1.4", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "webidl-conversions", + "moduleUrl": "https://github.com/jsdom/webidl-conversions", + "moduleVersion": "3.0.1", + "moduleLicense": "BSD-2-Clause", + "moduleLicenseUrl": "https://opensource.org/licenses/BSD-2-Clause" + }, + { + "moduleName": "whatwg-url", + "moduleUrl": "https://github.com/jsdom/whatwg-url", + "moduleVersion": "5.0.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "wide-align", + "moduleUrl": "https://github.com/iarna/wide-align", + "moduleVersion": "1.1.5", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "wrappy", + "moduleUrl": "https://github.com/npm/wrappy", + "moduleVersion": "1.0.2", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "yallist", + "moduleUrl": "https://github.com/isaacs/yallist", + "moduleVersion": "4.0.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + }, + { + "moduleName": "yallist", + "moduleUrl": "https://github.com/isaacs/yallist", + "moduleVersion": "5.0.0", + "moduleLicense": "BlueOak-1.0.0", + "moduleLicenseUrl": "" + }, + { + "moduleName": "yaml", + "moduleUrl": "https://github.com/eemeli/yaml", + "moduleVersion": "1.10.2", + "moduleLicense": "ISC", + "moduleLicenseUrl": "https://opensource.org/licenses/ISC" + } + ] +} \ No newline at end of file From 24a9104ebffc57ab886187308b3040ce35d576d6 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:14:05 +0100 Subject: [PATCH 18/29] Fix GB language file (#4087) Language file fix Co-authored-by: Connor Yoh --- frontend/public/locales/en-GB/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c051bf965..089562ed6 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -349,7 +349,7 @@ }, "convert": { "title": "Convert", - "desc": "Convert files between different formats", + "desc": "Convert files between different formats" }, "imageToPdf": { "title": "Image to PDF", From 90f0c5826a6f09db54a1b27e1c8d427e24aef562 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 4 Aug 2025 15:01:36 +0100 Subject: [PATCH 19/29] Added structure for filemanager (#4078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overview Replaced scattered file inputs with a unified modal-based upload system. Users now upload files via a global Files button with intelligent tool-aware filtering. Key Changes 🔄 New Upload Flow - Before: Direct file inputs throughout the UI - After: Single Files button → Modal → Tool filters files automatically 🎯 Smart File Filtering - Modal shows only supported file types based on selected tool - Visual indicators for unsupported files (grayed out + badges) - Automatic duplicate detection ✨ Enhanced UX - Files button shows active state when modal is open - Consistent upload experience across all tools - Professional modal workflow Architecture New Components FilesModalProvider → FileUploadModal → Tool-aware filtering Button System Redesign type: 'navigation' | 'modal' | 'action' // Only navigation buttons stay active // Modal buttons show active when modal open Files Changed - ✅ QuickAccessBar.tsx - Added Files button - ✅ FileUploadModal.tsx - New tool-aware modal - ✅ HomePage.tsx - Integrated modal system - ✅ ConvertE2E.spec.ts - Updated tests for modal workflow Benefits - Unified UX: One place to upload files - Smart Filtering: Only see relevant file types - Better Architecture: Clean separation of concerns - Improved Testing: Reliable test automation Migration: File uploads now go through Files button → modal instead of direct inputs. All existing functionality preserved. --------- Co-authored-by: Connor Yoh --- frontend/src/App.tsx | 5 +- .../src/components/fileEditor/FileEditor.tsx | 68 +++++++--------- .../src/components/shared/FileUploadModal.tsx | 36 +++++++++ .../src/components/shared/LandingPage.tsx | 30 +++++++ .../src/components/shared/QuickAccessBar.tsx | 79 ++++++++++++------- .../tools/convert/ConvertSettings.tsx | 4 +- frontend/src/contexts/FilesModalContext.tsx | 30 +++++++ frontend/src/hooks/useFileHandler.ts | 27 +++++++ frontend/src/hooks/useFilesModal.ts | 57 +++++++++++++ frontend/src/pages/HomePage.tsx | 67 +++++----------- frontend/src/tests/convert/ConvertE2E.spec.ts | 55 +++++++------ 11 files changed, 318 insertions(+), 140 deletions(-) create mode 100644 frontend/src/components/shared/FileUploadModal.tsx create mode 100644 frontend/src/components/shared/LandingPage.tsx create mode 100644 frontend/src/contexts/FilesModalContext.tsx create mode 100644 frontend/src/hooks/useFileHandler.ts create mode 100644 frontend/src/hooks/useFilesModal.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de5001850..852204b25 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; import { FileContextProvider } from './contexts/FileContext'; +import { FilesModalProvider } from './contexts/FilesModalContext'; import HomePage from './pages/HomePage'; // Import global styles @@ -11,7 +12,9 @@ export default function App() { return ( - + + + ); diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index b4222d9ae..ca5f594b8 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -665,46 +665,35 @@ const FileEditor = ({ return ( - - + + + - - - {showBulkActions && !toolMode && ( - <> - - - - - )} - - {/* Load from storage and upload buttons */} - {showUpload && ( - <> - - - - + + - - - )} - + + )} + {files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? ( @@ -866,7 +855,8 @@ const FileEditor = ({ {error} )} - + + ); }; diff --git a/frontend/src/components/shared/FileUploadModal.tsx b/frontend/src/components/shared/FileUploadModal.tsx new file mode 100644 index 000000000..a83e96e62 --- /dev/null +++ b/frontend/src/components/shared/FileUploadModal.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Modal } from '@mantine/core'; +import FileUploadSelector from './FileUploadSelector'; +import { useFilesModalContext } from '../../contexts/FilesModalContext'; +import { Tool } from '../../types/tool'; + +interface FileUploadModalProps { + selectedTool?: Tool | null; +} + +const FileUploadModal: React.FC = ({ selectedTool }) => { + const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext(); + + + return ( + + + + ); +}; + +export default FileUploadModal; \ No newline at end of file diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx new file mode 100644 index 000000000..977f1f280 --- /dev/null +++ b/frontend/src/components/shared/LandingPage.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Container, Stack, Text, Button } from '@mantine/core'; +import FolderIcon from '@mui/icons-material/FolderRounded'; +import { useFilesModalContext } from '../../contexts/FilesModalContext'; + +interface LandingPageProps { + title: string; +} + +const LandingPage = ({ title }: LandingPageProps) => { + const { openFilesModal } = useFilesModalContext(); + return ( + + + + {title} + + + + + ); +}; + +export default LandingPage; \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 22a49617e..2f78a0a9f 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -11,6 +11,7 @@ import { useRainbowThemeContext } from "./RainbowThemeProvider"; import rainbowStyles from '../../styles/rainbow.module.css'; import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; +import { useFilesModalContext } from '../../contexts/FilesModalContext'; import './QuickAccessBar.css'; interface QuickAccessBarProps { @@ -30,6 +31,7 @@ interface ButtonConfig { isRound?: boolean; size?: 'sm' | 'md' | 'lg' | 'xl'; onClick: () => void; + type?: 'navigation' | 'modal' | 'action'; // navigation = main nav, modal = triggers modal, action = other actions } function NavHeader({ @@ -111,11 +113,16 @@ const QuickAccessBar = ({ readerMode, }: QuickAccessBarProps) => { const { isRainbowMode } = useRainbowThemeContext(); + const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); const isOverflow = useIsOverflowing(scrollableRef); + const handleFilesButtonClick = () => { + openFilesModal(); + }; + const buttonConfigs: ButtonConfig[] = [ { id: 'read', @@ -124,6 +131,7 @@ const QuickAccessBar = ({ tooltip: 'Read documents', size: 'lg', isRound: false, + type: 'navigation', onClick: () => { setActiveButton('read'); onReaderToggle(); @@ -139,6 +147,7 @@ const QuickAccessBar = ({ tooltip: 'Sign your document', size: 'lg', isRound: false, + type: 'navigation', onClick: () => setActiveButton('sign') }, { @@ -148,6 +157,7 @@ const QuickAccessBar = ({ tooltip: 'Automate workflows', size: 'lg', isRound: false, + type: 'navigation', onClick: () => setActiveButton('automate') }, { @@ -157,7 +167,8 @@ const QuickAccessBar = ({ tooltip: 'Manage files', isRound: true, size: 'lg', - onClick: () => setActiveButton('files') + type: 'modal', + onClick: handleFilesButtonClick }, { id: 'activity', @@ -169,6 +180,7 @@ const QuickAccessBar = ({ tooltip: 'View activity and analytics', isRound: true, size: 'lg', + type: 'navigation', onClick: () => setActiveButton('activity') }, { @@ -177,6 +189,7 @@ const QuickAccessBar = ({ icon: , tooltip: 'Configure settings', size: 'lg', + type: 'modal', onClick: () => { setConfigModalOpen(true); } @@ -190,8 +203,16 @@ const QuickAccessBar = ({ return config.isRound ? CIRCULAR_BORDER_RADIUS : ROUND_BORDER_RADIUS; }; + const isButtonActive = (config: ButtonConfig): boolean => { + return ( + (config.type === 'navigation' && activeButton === config.id) || + (config.type === 'modal' && config.id === 'files' && isFilesModalOpen) || + (config.type === 'modal' && config.id === 'config' && configModalOpen) + ); + }; + const getButtonStyle = (config: ButtonConfig) => { - const isActive = activeButton === config.id; + const isActive = isButtonActive(config); if (isActive) { return { @@ -202,7 +223,7 @@ const QuickAccessBar = ({ }; } - // Inactive state - use consistent inactive colors + // Inactive state for all buttons return { backgroundColor: 'var(--icon-inactive-bg)', color: 'var(--icon-inactive-color)', @@ -254,13 +275,14 @@ const QuickAccessBar = ({ variant="subtle" onClick={config.onClick} style={getButtonStyle(config)} - className={activeButton === config.id ? 'activeIconScale' : ''} + className={isButtonActive(config) ? 'activeIconScale' : ''} + data-testid={`${config.id}-button`} > {config.icon} - + {config.name}
@@ -281,30 +303,29 @@ const QuickAccessBar = ({
{/* Config button at the bottom */} - -
- { - setConfigModalOpen(true); - }} - style={{ - backgroundColor: 'var(--icon-inactive-bg)', - color: 'var(--icon-inactive-color)', - border: 'none', - borderRadius: '8px', - }} - > - - - - - - Config - -
-
+ {buttonConfigs + .filter(config => config.id === 'config') + .map(config => ( + +
+ + + {config.icon} + + + + {config.name} + +
+
+ ))}
diff --git a/frontend/src/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx index fa6134f54..a3051c88f 100644 --- a/frontend/src/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertSettings.tsx @@ -198,7 +198,7 @@ const ConvertSettings = ({ (null); + +export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { addToActiveFiles, addMultipleFiles } = useFileHandler(); + + const filesModal = useFilesModal({ + onFileSelect: addToActiveFiles, + onFilesSelect: addMultipleFiles, + }); + + return ( + + {children} + + ); +}; + +export const useFilesModalContext = () => { + const context = useContext(FilesModalContext); + if (!context) { + throw new Error('useFilesModalContext must be used within FilesModalProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useFileHandler.ts b/frontend/src/hooks/useFileHandler.ts new file mode 100644 index 000000000..efd988906 --- /dev/null +++ b/frontend/src/hooks/useFileHandler.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { useFileContext } from '../contexts/FileContext'; + +export const useFileHandler = () => { + const { activeFiles, addFiles } = useFileContext(); + + const addToActiveFiles = useCallback(async (file: File) => { + const exists = activeFiles.some(f => f.name === file.name && f.size === file.size); + if (!exists) { + await addFiles([file]); + } + }, [activeFiles, addFiles]); + + const addMultipleFiles = useCallback(async (files: File[]) => { + const newFiles = files.filter(file => + !activeFiles.some(f => f.name === file.name && f.size === file.size) + ); + if (newFiles.length > 0) { + await addFiles(newFiles); + } + }, [activeFiles, addFiles]); + + return { + addToActiveFiles, + addMultipleFiles, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useFilesModal.ts b/frontend/src/hooks/useFilesModal.ts new file mode 100644 index 000000000..49e9f2c5e --- /dev/null +++ b/frontend/src/hooks/useFilesModal.ts @@ -0,0 +1,57 @@ +import { useState, useCallback } from 'react'; + +export interface UseFilesModalReturn { + isFilesModalOpen: boolean; + openFilesModal: () => void; + closeFilesModal: () => void; + onFileSelect?: (file: File) => void; + onFilesSelect?: (files: File[]) => void; + onModalClose?: () => void; + setOnModalClose: (callback: () => void) => void; +} + +interface UseFilesModalProps { + onFileSelect?: (file: File) => void; + onFilesSelect?: (files: File[]) => void; +} + +export const useFilesModal = ({ + onFileSelect, + onFilesSelect +}: UseFilesModalProps = {}): UseFilesModalReturn => { + const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); + const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); + + const openFilesModal = useCallback(() => { + setIsFilesModalOpen(true); + }, []); + + const closeFilesModal = useCallback(() => { + setIsFilesModalOpen(false); + onModalClose?.(); + }, [onModalClose]); + + const handleFileSelect = useCallback((file: File) => { + onFileSelect?.(file); + closeFilesModal(); + }, [onFileSelect, closeFilesModal]); + + const handleFilesSelect = useCallback((files: File[]) => { + onFilesSelect?.(files); + closeFilesModal(); + }, [onFilesSelect, closeFilesModal]); + + const setModalCloseCallback = useCallback((callback: () => void) => { + setOnModalClose(() => callback); + }, []); + + return { + isFilesModalOpen, + openFilesModal, + closeFilesModal, + onFileSelect: handleFileSelect, + onFilesSelect: handleFilesSelect, + onModalClose, + setOnModalClose: setModalCloseCallback, + }; +}; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index cde8d3320..cccce7667 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,9 +1,10 @@ -import React, { useState, useCallback, useEffect} from "react"; +import React, { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from 'react-i18next'; import { useFileContext } from "../contexts/FileContext"; import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; import { useToolManagement } from "../hooks/useToolManagement"; -import { Group, Box, Button, Container } from "@mantine/core"; +import { useFileHandler } from "../hooks/useFileHandler"; +import { Group, Box, Button } from "@mantine/core"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; import { PageEditorFunctions } from "../types/pageEditor"; import rainbowStyles from '../styles/rainbow.module.css'; @@ -14,17 +15,19 @@ import FileEditor from "../components/fileEditor/FileEditor"; import PageEditor from "../components/pageEditor/PageEditor"; import PageEditorControls from "../components/pageEditor/PageEditorControls"; import Viewer from "../components/viewer/Viewer"; -import FileUploadSelector from "../components/shared/FileUploadSelector"; import ToolRenderer from "../components/tools/ToolRenderer"; import QuickAccessBar from "../components/shared/QuickAccessBar"; +import LandingPage from "../components/shared/LandingPage"; +import FileUploadModal from "../components/shared/FileUploadModal"; function HomePageContent() { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const fileContext = useFileContext(); - const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext; + const { activeFiles, currentView, setCurrentView } = fileContext; const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); + const { addToActiveFiles } = useFileHandler(); const { selectedToolKey, @@ -33,6 +36,7 @@ function HomePageContent() { selectTool, clearToolSelection, } = useToolManagement(); + const [sidebarsVisible, setSidebarsVisible] = useState(true); const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); const [readerMode, setReaderMode] = useState(false); @@ -77,12 +81,6 @@ function HomePageContent() { setCurrentView(view as any); }, [setCurrentView]); - const addToActiveFiles = useCallback(async (file: File) => { - const exists = activeFiles.some(f => f.name === file.name && f.size === file.size); - if (!exists) { - await addFiles([file]); - } - }, [activeFiles, addFiles]); @@ -183,26 +181,12 @@ function HomePageContent() { }} > {!activeFiles[0] ? ( - - { - addToActiveFiles(file); - }} - onFilesSelect={(files) => { - files.forEach(addToActiveFiles); - }} - accept={["*/*"]} - supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} - loading={false} - showRecentFiles={true} - maxRecentFiles={8} - /> - + ) : currentView === "fileEditor" ? ( ) : ( - - { - addToActiveFiles(file); - }} - onFilesSelect={(files) => { - files.forEach(addToActiveFiles); - }} - accept={["*/*"]} - supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} - loading={false} - showRecentFiles={true} - maxRecentFiles={8} - /> - + )} + + {/* Global Modals */} + ); } diff --git a/frontend/src/tests/convert/ConvertE2E.spec.ts b/frontend/src/tests/convert/ConvertE2E.spec.ts index e60f7826c..90d203b55 100644 --- a/frontend/src/tests/convert/ConvertE2E.spec.ts +++ b/frontend/src/tests/convert/ConvertE2E.spec.ts @@ -127,6 +127,27 @@ const getExpectedExtension = (toFormat: string): string => { return extensionMap[toFormat] || '.pdf'; }; +/** + * Helper function to upload files through the modal system + */ +async function uploadFileViaModal(page: Page, filePath: string) { + // Click the Files button in the QuickAccessBar to open the modal + await page.click('[data-testid="files-button"]'); + + // Wait for the modal to open + await page.waitForSelector('.mantine-Modal-overlay', { state: 'visible' }, { timeout: 5000 }); + //await page.waitForSelector('[data-testid="file-upload-modal"]', { timeout: 5000 }); + + // Upload the file through the modal's file input + await page.setInputFiles('input[type="file"]', filePath); + + // Wait for the file to be processed and the modal to close + await page.waitForSelector('[data-testid="file-upload-modal"]', { state: 'hidden' }); + + // Wait for the file thumbnail to appear in the main interface + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); +} + /** * Generic test function for any conversion */ @@ -288,8 +309,8 @@ test.describe('Convert Tool E2E Tests', () => { // Wait for the page to load await page.waitForLoadState('networkidle'); - // Wait for the file upload area to appear (shown when no active files) - await page.waitForSelector('[data-testid="file-dropzone"]', { timeout: 10000 }); + // Wait for the QuickAccessBar to appear + await page.waitForSelector('[data-testid="files-button"]', { timeout: 10000 }); }); test.describe('Dynamic Conversion Tests', () => { @@ -302,8 +323,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -314,8 +334,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -326,8 +345,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -338,8 +356,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -350,8 +367,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -362,8 +378,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -374,8 +389,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -386,8 +400,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -398,8 +411,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -410,8 +422,7 @@ test.describe('Convert Tool E2E Tests', () => { // Test that disabled conversions don't appear in dropdowns when they shouldn't test('should not show conversion button when no valid conversions available', async ({ page }) => { // This test ensures the convert button is disabled when no valid conversion is possible - await page.setInputFiles('input[type="file"]', TEST_FILES.pdf); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, TEST_FILES.pdf); // Click the Convert tool button await page.click('[data-testid="tool-convert"]'); From 2947abd2fdc53248b4d1b147b767a0b8a47fe0f6 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 5 Aug 2025 19:55:35 +0100 Subject: [PATCH 20/29] Update PR-Auto-Deploy-V2.yml --- .github/workflows/PR-Auto-Deploy-V2.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 7094ef72d..3ece9fc4f 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -48,6 +48,7 @@ jobs: "DarioGii" "ConnorYoh" "EthanHealy01" + "jbrunton96" ) # Check if author is in the authorized list From 2b7671dc63f6abc987c1aeb37753f746e475ce17 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 5 Aug 2025 19:55:53 +0100 Subject: [PATCH 21/29] Update PR-Demo-Comment-with-react.yml --- .github/workflows/PR-Demo-Comment-with-react.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 877a78524..d08c4ad04 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -29,6 +29,7 @@ jobs: github.event.comment.user.login == 'reecebrowne' || github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'EthanHealy01' || + github.event.comment.user.login == 'jbrunton96' || github.event.comment.user.login == 'ConnorYoh' ) outputs: From 23aa43bd785e93a78d2f36712b82dd2e3c67c62d Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:12:09 +0100 Subject: [PATCH 22/29] build fix (#4131) into pr-4113 # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/build.yml | 2 +- build.gradle | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b0e51a8bf..b2a9abe17 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,7 +53,7 @@ jobs: with: gradle-version: 8.14 - name: Build with Gradle and spring security ${{ matrix.spring-security }} - run: ./gradlew clean build + run: ./gradlew clean build -PnoSpotless env: DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }} - name: Check Test Reports Exist diff --git a/build.gradle b/build.gradle index 0c62a0e07..4ad54559e 100644 --- a/build.gradle +++ b/build.gradle @@ -203,9 +203,17 @@ subprojects { tasks.withType(JavaCompile).configureEach { options.encoding = "UTF-8" - dependsOn "spotlessApply" + if (!project.hasProperty("noSpotless")) { + dependsOn "spotlessApply" + } +} +gradle.taskGraph.whenReady { graph -> + if (project.hasProperty("noSpotless")) { + tasks.matching { it.name.startsWith("spotless") }.configureEach { + enabled = false + } + } } - licenseReport { projects = [project] renderers = [new JsonReportRenderer()] From 7dedf45b5dd626a00f87c9793a65c57f26ee4f5d Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 7 Aug 2025 13:52:40 +0100 Subject: [PATCH 23/29] prune fix (#4133) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/PR-Auto-Deploy-V2.yml | 6 +++--- .github/workflows/deploy-on-v2-commit.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 3ece9fc4f..bd546078d 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -352,10 +352,10 @@ jobs: docker-compose up -d # Clean up unused Docker resources to save space - docker system prune -af --volumes + docker system prune -af --volumes || true # Clean up old backend/frontend images (older than 2 weeks) - docker image prune -af --filter "until=336h" --filter "label!=keep=true" + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true ENDSSH # Set port for output @@ -490,7 +490,7 @@ jobs: fi # Clean up old unused images (older than 2 weeks) but keep recent ones for reuse - docker image prune -af --filter "until=336h" --filter "label!=keep=true" + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true # Note: We don't remove the commit-based images since they can be reused across PRs # Only remove PR-specific containers and directories diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index 8c3218869..941db40cf 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -177,8 +177,8 @@ jobs: docker-compose down || true docker-compose pull docker-compose up -d - docker system prune -af --volumes - docker image prune -af --filter "until=336h" --filter "label!=keep=true" + docker system prune -af --volumes || true + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true ENDSSH - name: Cleanup temporary files From f4e4831c0d1838851eaa85fc077f6c8213130b7a Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:02:08 +0100 Subject: [PATCH 24/29] Feature/v2/landing page (#4128) # Description of Changes - Added landing page styling - Clicking add files now opens native file manager --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../branding/StirlingPDFLogoBlackText.svg | 4 + .../branding/StirlingPDFLogoGreyText.svg | 4 + .../branding/StirlingPDFLogoNoTextDark.svg | 4 + .../branding/StirlingPDFLogoNoTextLight.svg | 4 + .../branding/StirlingPDFLogoWhiteText.svg | 4 + .../src/components/shared/LandingPage.tsx | 158 +++++++++++++++--- frontend/src/styles/theme.css | 53 ++++++ 7 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 frontend/public/branding/StirlingPDFLogoBlackText.svg create mode 100644 frontend/public/branding/StirlingPDFLogoGreyText.svg create mode 100644 frontend/public/branding/StirlingPDFLogoNoTextDark.svg create mode 100644 frontend/public/branding/StirlingPDFLogoNoTextLight.svg create mode 100644 frontend/public/branding/StirlingPDFLogoWhiteText.svg diff --git a/frontend/public/branding/StirlingPDFLogoBlackText.svg b/frontend/public/branding/StirlingPDFLogoBlackText.svg new file mode 100644 index 000000000..a4a1a1f87 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoBlackText.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/branding/StirlingPDFLogoGreyText.svg b/frontend/public/branding/StirlingPDFLogoGreyText.svg new file mode 100644 index 000000000..deac1ee16 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoGreyText.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/branding/StirlingPDFLogoNoTextDark.svg b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg new file mode 100644 index 000000000..6c99f5001 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/branding/StirlingPDFLogoNoTextLight.svg b/frontend/public/branding/StirlingPDFLogoNoTextLight.svg new file mode 100644 index 000000000..a0fa9cee5 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoNoTextLight.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/branding/StirlingPDFLogoWhiteText.svg b/frontend/public/branding/StirlingPDFLogoWhiteText.svg new file mode 100644 index 000000000..ade693787 --- /dev/null +++ b/frontend/public/branding/StirlingPDFLogoWhiteText.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 977f1f280..40b765547 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -1,28 +1,146 @@ import React from 'react'; -import { Container, Stack, Text, Button } from '@mantine/core'; -import FolderIcon from '@mui/icons-material/FolderRounded'; -import { useFilesModalContext } from '../../contexts/FilesModalContext'; +import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import AddIcon from '@mui/icons-material/Add'; +import { useTranslation } from 'react-i18next'; +import { useFileHandler } from '../../hooks/useFileHandler'; -interface LandingPageProps { - title: string; -} +const LandingPage = () => { + const { addMultipleFiles } = useFileHandler(); + const fileInputRef = React.useRef(null); + const { colorScheme } = useMantineColorScheme(); + const { t } = useTranslation(); + + const handleFileDrop = async (files: File[]) => { + await addMultipleFiles(files); + }; + + const handleAddFilesClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileSelect = async (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + if (files.length > 0) { + await addMultipleFiles(files); + } + // Reset the input so the same file can be selected again + event.target.value = ''; + }; -const LandingPage = ({ title }: LandingPageProps) => { - const { openFilesModal } = useFilesModalContext(); return ( - - - - {title} - - - + Stirling PDF Logo +
+
+ {/* Logo positioned absolutely in top right corner */} + + + {/* Centered content container */} +
+ {/* Stirling PDF Branding */} + + Stirling PDF + + + {/* Add Files Button */} + + + {/* Hidden file input for native file picker */} + + +
+ + {/* Instruction Text */} + + {t('fileUpload.dragFilesInOrClick', 'Drag files in or click "Add Files" to browse')} + +
+ ); }; diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 7cdb46c55..71443411f 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -106,6 +106,30 @@ /* Inactive icon colors for light mode */ --icon-inactive-bg: #9CA3AF; --icon-inactive-color: #FFFFFF; + + --accent-interactive: #4A90E2; + --text-instruction: #4A90E2; + --text-brand: var(--color-gray-700); + --text-brand-accent: #DC2626; + + /* container */ + --landing-paper-bg: var(--bg-surface); + --landing-inner-paper-bg: #EEF8FF; + --landing-inner-paper-border: #CDEAFF; + --landing-button-bg: var(--bg-surface); + --landing-button-color: var(--icon-tools-bg); + --landing-button-border: #E0F2F7; + --landing-button-hover-bg: rgb(251, 251, 251); + + /* drop state */ + --landing-drop-paper-bg: #E3F2FD; + --landing-drop-inner-paper-bg: #BBDEFB; + --landing-drop-inner-paper-border: #90CAF9; + + /* shadows */ + --drop-shadow-color: rgba(0, 0, 0, 0.08); + --drop-shadow-color-strong: rgba(0, 0, 0, 0.04); + --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04)); } [data-mantine-color-scheme="dark"] { @@ -177,6 +201,29 @@ --icon-inactive-bg: #2A2F36; --icon-inactive-color: #6E7581; + --accent-interactive: #ffffff; + --text-instruction: #ffffff; + --text-brand: var(--color-gray-800); + --text-brand-accent: #EF4444; + + /* container */ + --landing-paper-bg: #171A1F; + --landing-inner-paper-bg: var(--bg-raised); + --landing-inner-paper-border: #2D3237; + --landing-button-bg: #2B3037; + --landing-button-color: #ffffff; + --landing-button-border: #2D3237; + --landing-button-hover-bg: #4c525b; + + /* drop state */ + --landing-drop-paper-bg: #1A2332; + --landing-drop-inner-paper-bg: #2A3441; + --landing-drop-inner-paper-border: #3A4451; + + /* shadows */ + --drop-shadow-color: rgba(255, 255, 255, 0.08); + --drop-shadow-color-strong: rgba(255, 255, 255, 0.04); + --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06)) drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04)); /* Adjust shadows for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); @@ -185,6 +232,12 @@ --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4); } +/* Dropzone drop state styling */ +[data-accept] .dropzone-inner { + background-color: var(--landing-drop-inner-paper-bg) !important; + border-color: var(--landing-drop-inner-paper-border) !important; +} + /* Smooth transitions for theme switching */ * { transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; From 9861332040612ed154eedce120c488769fb29cc0 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:09:41 +0100 Subject: [PATCH 25/29] Feature/v2/tooltips (#4112) # Description of Changes - added tooltips to ocr and compress - added the tooltip component which can be used either directly, or through the toolstep component --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/public/logo-tooltip.svg | 4 + .../src/components/shared/QuickAccessBar.tsx | 36 +-- frontend/src/components/shared/Tooltip.tsx | 243 ++++++++++++++++++ .../shared/tooltip/Tooltip.README.md | 223 ++++++++++++++++ .../shared/tooltip/Tooltip.module.css | 191 ++++++++++++++ .../shared/tooltip/TooltipContent.tsx | 78 ++++++ .../src/components/tools/shared/ToolStep.tsx | 49 +++- .../src/components/tooltips/CompressTips.ts | 30 +++ frontend/src/components/tooltips/OCRTips.ts | 36 +++ frontend/src/contexts/SidebarContext.tsx | 47 ++++ frontend/src/hooks/useTooltipPosition.ts | 177 +++++++++++++ frontend/src/pages/HomePage.tsx | 27 +- frontend/src/styles/theme.css | 15 ++ frontend/src/tools/Compress.tsx | 3 + frontend/src/tools/OCR.tsx | 3 + frontend/src/types/sidebar.ts | 46 ++++ frontend/src/types/tips.ts | 13 + frontend/src/utils/genericUtils.ts | 42 +++ frontend/src/utils/sidebarUtils.ts | 34 +++ 19 files changed, 1256 insertions(+), 41 deletions(-) create mode 100644 frontend/public/logo-tooltip.svg create mode 100644 frontend/src/components/shared/Tooltip.tsx create mode 100644 frontend/src/components/shared/tooltip/Tooltip.README.md create mode 100644 frontend/src/components/shared/tooltip/Tooltip.module.css create mode 100644 frontend/src/components/shared/tooltip/TooltipContent.tsx create mode 100644 frontend/src/components/tooltips/CompressTips.ts create mode 100644 frontend/src/components/tooltips/OCRTips.ts create mode 100644 frontend/src/contexts/SidebarContext.tsx create mode 100644 frontend/src/hooks/useTooltipPosition.ts create mode 100644 frontend/src/types/sidebar.ts create mode 100644 frontend/src/types/tips.ts create mode 100644 frontend/src/utils/genericUtils.ts create mode 100644 frontend/src/utils/sidebarUtils.ts diff --git a/frontend/public/logo-tooltip.svg b/frontend/public/logo-tooltip.svg new file mode 100644 index 000000000..2d53f287c --- /dev/null +++ b/frontend/public/logo-tooltip.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 2f78a0a9f..fb27b1c2c 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, forwardRef } from "react"; import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core"; import MenuBookIcon from "@mui/icons-material/MenuBookRounded"; import AppsIcon from "@mui/icons-material/AppsRounded"; @@ -8,32 +8,12 @@ import FolderIcon from "@mui/icons-material/FolderRounded"; import PersonIcon from "@mui/icons-material/PersonRounded"; import NotificationsIcon from "@mui/icons-material/NotificationsRounded"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; -import rainbowStyles from '../../styles/rainbow.module.css'; import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; +import { QuickAccessBarProps, ButtonConfig } from '../../types/sidebar'; import './QuickAccessBar.css'; -interface QuickAccessBarProps { - onToolsClick: () => void; - onReaderToggle: () => void; - selectedToolKey?: string; - toolRegistry: any; - leftPanelView: 'toolPicker' | 'toolContent'; - readerMode: boolean; -} - -interface ButtonConfig { - id: string; - name: string; - icon: React.ReactNode; - tooltip: string; - isRound?: boolean; - size?: 'sm' | 'md' | 'lg' | 'xl'; - onClick: () => void; - type?: 'navigation' | 'modal' | 'action'; // navigation = main nav, modal = triggers modal, action = other actions -} - function NavHeader({ activeButton, setActiveButton, @@ -104,14 +84,10 @@ function NavHeader({ ); } -const QuickAccessBar = ({ +const QuickAccessBar = forwardRef(({ onToolsClick, onReaderToggle, - selectedToolKey, - toolRegistry, - leftPanelView, - readerMode, -}: QuickAccessBarProps) => { +}, ref) => { const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const [configModalOpen, setConfigModalOpen] = useState(false); @@ -234,6 +210,8 @@ const QuickAccessBar = ({ return (
{/* Fixed header outside scrollable area */} @@ -335,6 +313,6 @@ const QuickAccessBar = ({ />
); -}; +}); export default QuickAccessBar; \ No newline at end of file diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx new file mode 100644 index 000000000..6deda77c4 --- /dev/null +++ b/frontend/src/components/shared/Tooltip.tsx @@ -0,0 +1,243 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils'; +import { useTooltipPosition } from '../../hooks/useTooltipPosition'; +import { TooltipContent, TooltipTip } from './tooltip/TooltipContent'; +import { useSidebarContext } from '../../contexts/SidebarContext'; +import styles from './tooltip/Tooltip.module.css' + +export interface TooltipProps { + sidebarTooltip?: boolean; + position?: 'right' | 'left' | 'top' | 'bottom'; + content?: React.ReactNode; + tips?: TooltipTip[]; + children: React.ReactElement; + offset?: number; + maxWidth?: number | string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + arrow?: boolean; + portalTarget?: HTMLElement; + header?: { + title: string; + logo?: React.ReactNode; + }; +} + +export const Tooltip: React.FC = ({ + sidebarTooltip = false, + position = 'right', + content, + tips, + children, + offset: gap = 8, + maxWidth = 280, + open: controlledOpen, + onOpenChange, + arrow = false, + portalTarget, + header, +}) => { + const [internalOpen, setInternalOpen] = useState(false); + const [isPinned, setIsPinned] = useState(false); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); + + // Get sidebar context for tooltip positioning + const sidebarContext = sidebarTooltip ? useSidebarContext() : null; + + // Always use controlled mode - if no controlled props provided, use internal state + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + + const handleOpenChange = (newOpen: boolean) => { + if (isControlled) { + onOpenChange?.(newOpen); + } else { + setInternalOpen(newOpen); + } + + // Reset pin state when closing + if (!newOpen) { + setIsPinned(false); + } + }; + + const handleTooltipClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsPinned(true); + }; + + const handleDocumentClick = (e: MouseEvent) => { + // If tooltip is pinned and we click outside of it, unpin it + if (isPinned && isClickOutside(e, tooltipRef.current)) { + setIsPinned(false); + handleOpenChange(false); + } + }; + + // Use the positioning hook + const { coords, positionReady } = useTooltipPosition({ + open, + sidebarTooltip, + position, + gap, + triggerRef, + tooltipRef, + sidebarRefs: sidebarContext?.sidebarRefs, + sidebarState: sidebarContext?.sidebarState + }); + + // Add document click listener for unpinning + useEffect(() => { + if (isPinned) { + return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener); + } + }, [isPinned]); + + const getArrowClass = () => { + // No arrow for sidebar tooltips + if (sidebarTooltip) return null; + + switch (position) { + case 'top': return "tooltip-arrow tooltip-arrow-top"; + case 'bottom': return "tooltip-arrow tooltip-arrow-bottom"; + case 'left': return "tooltip-arrow tooltip-arrow-left"; + case 'right': return "tooltip-arrow tooltip-arrow-right"; + default: return "tooltip-arrow tooltip-arrow-right"; + } + }; + + const getArrowStyleClass = (arrowClass: string) => { + const styleKey = arrowClass.split(' ')[1]; + // Handle both kebab-case and camelCase CSS module exports + return styles[styleKey as keyof typeof styles] || + styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] || + ''; + }; + + // Only show tooltip when position is ready and correct + const shouldShowTooltip = open && (sidebarTooltip ? positionReady : true); + + const tooltipElement = shouldShowTooltip ? ( +
+ {isPinned && ( + + )} + {arrow && getArrowClass() && ( +
+ )} + {header && ( +
+
+ {header.logo || Stirling PDF} +
+ {header.title} +
+ )} + +
+ ) : null; + + const handleMouseEnter = (e: React.MouseEvent) => { + // Clear any existing timeout + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + + // Only show on hover if not pinned + if (!isPinned) { + handleOpenChange(true); + } + + (children.props as any)?.onMouseEnter?.(e); + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + // Only hide on mouse leave if not pinned + if (!isPinned) { + // Add a small delay to prevent flickering + hoverTimeoutRef.current = setTimeout(() => { + handleOpenChange(false); + }, 100); + } + + (children.props as any)?.onMouseLeave?.(e); + }; + + const handleClick = (e: React.MouseEvent) => { + // Toggle pin state on click + if (open) { + setIsPinned(!isPinned); + } else { + handleOpenChange(true); + setIsPinned(true); + } + + (children.props as any)?.onClick?.(e); + }; + + // Take the child element and add tooltip behavior to it + const childWithTooltipHandlers = React.cloneElement(children as any, { + // Keep track of the element for positioning + ref: (node: HTMLElement) => { + triggerRef.current = node; + // Don't break if the child already has a ref + const originalRef = (children as any).ref; + if (typeof originalRef === 'function') { + originalRef(node); + } else if (originalRef && typeof originalRef === 'object') { + originalRef.current = node; + } + }, + // Add mouse events to show/hide tooltip + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + onClick: handleClick, + }); + + return ( + <> + {childWithTooltipHandlers} + {portalTarget && document.body.contains(portalTarget) + ? tooltipElement && createPortal(tooltipElement, portalTarget) + : tooltipElement} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/shared/tooltip/Tooltip.README.md b/frontend/src/components/shared/tooltip/Tooltip.README.md new file mode 100644 index 000000000..df1a977b0 --- /dev/null +++ b/frontend/src/components/shared/tooltip/Tooltip.README.md @@ -0,0 +1,223 @@ +# Tooltip Component + +A flexible, accessible tooltip component that supports both regular positioning and special sidebar positioning logic with click-to-pin functionality. The tooltip is controlled by default, appearing on hover and pinning on click. + +## Features + +- 🎯 **Smart Positioning**: Automatically positions tooltips to stay within viewport bounds +- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements +- ♿ **Accessible**: Works with mouse interactions and click-to-pin functionality +- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX +- 🌙 **Theme Support**: Built-in dark mode and theme variable support +- ⚡ **Performance**: Memoized calculations and efficient event handling +- 📜 **Scrollable**: Content area scrolls when content exceeds max height +- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin +- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content +- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior + +## Behavior + +### Default Behavior (Controlled) +- **Hover**: Tooltips appear on hover with a small delay when leaving to prevent flickering +- **Click**: Click the trigger to pin the tooltip open +- **Click tooltip**: Pins the tooltip to keep it open +- **Click close button**: Unpins and closes the tooltip (red X button in top-right when pinned) +- **Click outside**: Unpins and closes the tooltip +- **Visual indicator**: Pinned tooltips have a blue border and close button + +### Manual Control (Optional) +- Use `open` and `onOpenChange` props for complete external control +- Useful for complex state management or custom interaction patterns + +## Basic Usage + +```tsx +import { Tooltip } from '@/components/shared'; + +function MyComponent() { + return ( + + + + ); +} +``` + +## API Reference + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `content` | `ReactNode` | - | Custom JSX content to display in the tooltip | +| `tips` | `TooltipTip[]` | - | Structured content with title, description, bullets, and optional body | +| `children` | `ReactElement` | **required** | Element that triggers the tooltip | +| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic | +| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Tooltip position (ignored if `sidebarTooltip` is true) | +| `offset` | `number` | `8` | Distance in pixels between trigger and tooltip | +| `maxWidth` | `number \| string` | `280` | Maximum width constraint for the tooltip | +| `open` | `boolean` | `undefined` | External open state (makes component fully controlled) | +| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback for external control | +| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element | +| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into | +| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo | + +### TooltipTip Interface + +```typescript +interface TooltipTip { + title?: string; // Optional pill label + description?: string; // Optional description text (supports HTML including tags) + bullets?: string[]; // Optional bullet points (supports HTML including tags) + body?: React.ReactNode; // Optional custom JSX for this tip +} +``` + +## Usage Examples + +### Default Behavior (Recommended) + +```tsx +// Simple tooltip with hover and click-to-pin + + + + +// Structured content with tips +Auto skips pages that already contain text.", + "Force re-processes every page.", + "Strict stops if text is found.", + "Learn more" + ] + } + ]} + header={{ + title: "Basic Settings Overview", + logo: Logo + }} +> + + +``` + +### Custom JSX Content + +```tsx + +

Custom Content

+

Any JSX you want here

+ + External link +
+ } +> + + +``` + +### Mixed Content (Tips + Custom JSX) + +```tsx +Additional custom content below tips
} +> + + +``` + +### Sidebar Tooltips + +```tsx +// For items in a sidebar/navigation + +
+ 📁 File Manager +
+
+``` + +### With Arrows + +```tsx + + + +``` + +### Manual Control (Advanced) + +```tsx +function ManualControlTooltip() { + const [open, setOpen] = useState(false); + + return ( + + + + ); +} +``` + +## Click-to-Pin Interaction + +### How to Use (Default Behavior) +1. **Hover** over the trigger element to show the tooltip +2. **Click** the trigger element to pin the tooltip open +3. **Click** the red X button in the top-right corner to close +4. **Click** anywhere outside the tooltip to close +5. **Click** the trigger again to toggle pin state + +### Visual States +- **Unpinned**: Normal tooltip appearance +- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner + +## Link Support + +The tooltip fully supports clickable links in all content areas: + +- **Descriptions**: Use `` in description strings +- **Bullets**: Use `` in bullet point strings +- **Body**: Use JSX `` elements in the body ReactNode +- **Content**: Use JSX `` elements in custom content + +Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`. + +## Positioning Logic + +### Regular Tooltips +- Uses the `position` prop to determine initial placement +- Automatically clamps to viewport boundaries +- Calculates optimal position based on trigger element's `getBoundingClientRect()` +- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped + +### Sidebar Tooltips +- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar +- Vertical positioning follows the trigger but clamps to viewport +- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position +- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed +- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`) +- **No arrows** - sidebar tooltips don't show arrows diff --git a/frontend/src/components/shared/tooltip/Tooltip.module.css b/frontend/src/components/shared/tooltip/Tooltip.module.css new file mode 100644 index 000000000..209f0dcbd --- /dev/null +++ b/frontend/src/components/shared/tooltip/Tooltip.module.css @@ -0,0 +1,191 @@ +/* Tooltip Container */ +.tooltip-container { + position: fixed; + border: 0.0625rem solid var(--border-default); + border-radius: 0.75rem; + background-color: var(--bg-raised); + box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05); + font-size: 0.875rem; + line-height: 1.5; + pointer-events: auto; + z-index: 9999; + transition: opacity 100ms ease-out, transform 100ms ease-out; + min-width: 25rem; + max-width: 50vh; + max-height: 80vh; + color: var(--text-primary); + display: flex; + flex-direction: column; +} + +/* Pinned tooltip indicator */ +.tooltip-container.pinned { + border-color: var(--primary-color, #3b82f6); + box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05), 0 0 0 0.125rem rgba(59, 130, 246, 0.1); +} + +/* Pinned tooltip header */ +.tooltip-container.pinned .tooltip-header { + background-color: var(--primary-color, #3b82f6); + color: white; + border-color: var(--primary-color, #3b82f6); +} + +/* Close button */ +.tooltip-pin-button { + position: absolute; + top: -0.5rem; + right: 0.5rem; + font-size: 0.875rem; + background: var(--bg-raised); + padding: 0.25rem; + border-radius: 0.25rem; + border: 0.0625rem solid var(--primary-color, #3b82f6); + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + min-height: 1.5rem; +} + +.tooltip-pin-button .material-symbols-outlined { + font-size: 1rem; + line-height: 1; +} + +.tooltip-pin-button:hover { + background-color: #ef4444 !important; + border-color: #ef4444 !important; +} + +/* Tooltip Header */ +.tooltip-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background-color: var(--tooltip-header-bg); + color: var(--tooltip-header-color); + font-size: 0.875rem; + font-weight: 500; + border-top-left-radius: 0.75rem; + border-top-right-radius: 0.75rem; + margin: -0.0625rem -0.0625rem 0 -0.0625rem; + border: 0.0625rem solid var(--tooltip-border); + flex-shrink: 0; +} + +.tooltip-logo { + width: 1rem; + height: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.tooltip-title { + flex: 1; +} + +/* Tooltip Body */ +.tooltip-body { + padding: 1rem !important; + color: var(--text-primary) !important; + font-size: 0.875rem !important; + line-height: 1.6 !important; + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.tooltip-body * { + color: var(--text-primary) !important; +} + +/* Link styling within tooltips */ +.tooltip-body a { + color: var(--link-color, #3b82f6) !important; + text-decoration: underline; + text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); + transition: color 0.2s ease, text-decoration-color 0.2s ease; +} + +.tooltip-body a:hover { + color: var(--link-hover-color, #2563eb) !important; + text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5)); +} + +.tooltip-container .tooltip-body { + color: var(--text-primary) !important; +} + +.tooltip-container .tooltip-body * { + color: var(--text-primary) !important; +} + +/* Ensure links maintain their styling */ +.tooltip-container .tooltip-body a { + color: var(--link-color, #3b82f6) !important; + text-decoration: underline; + text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); +} + +.tooltip-container .tooltip-body a:hover { + color: var(--link-hover-color, #2563eb) !important; + text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5)); +} + + +/* Tooltip Arrows */ +.tooltip-arrow { + position: absolute; + width: 0.5rem; + height: 0.5rem; + background: var(--bg-raised); + border: 0.0625rem solid var(--border-default); + transform: rotate(45deg); +} + + +.tooltip-arrow-sidebar { + top: 50%; + left: -0.25rem; + transform: translateY(-50%) rotate(45deg); + border-left: none; + border-bottom: none; +} + +.tooltip-arrow-top { + top: -0.25rem; + left: 50%; + transform: translateX(-50%) rotate(45deg); + border-top: none; + border-left: none; +} + +.tooltip-arrow-bottom { + bottom: -0.25rem; + left: 50%; + transform: translateX(-50%) rotate(45deg); + border-bottom: none; + border-right: none; +} + +.tooltip-arrow-left { + right: -0.25rem; + top: 50%; + transform: translateY(-50%) rotate(45deg); + border-left: none; + border-bottom: none; +} + +.tooltip-arrow-right { + left: -0.25rem; + top: 50%; + transform: translateY(-50%) rotate(45deg); + border-right: none; + border-top: none; +} \ No newline at end of file diff --git a/frontend/src/components/shared/tooltip/TooltipContent.tsx b/frontend/src/components/shared/tooltip/TooltipContent.tsx new file mode 100644 index 000000000..e3515e0e6 --- /dev/null +++ b/frontend/src/components/shared/tooltip/TooltipContent.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import styles from './Tooltip.module.css'; + +export interface TooltipTip { + title?: string; + description?: string; + bullets?: string[]; + body?: React.ReactNode; +} + +interface TooltipContentProps { + content?: React.ReactNode; + tips?: TooltipTip[]; +} + +export const TooltipContent: React.FC = ({ + content, + tips, +}) => { + return ( +
+
+ {tips ? ( + <> + {tips.map((tip, index) => ( +
+ {tip.title && ( +
+ {tip.title} +
+ )} + {tip.description && ( +

+ )} + {tip.bullets && tip.bullets.length > 0 && ( +

    + {tip.bullets.map((bullet, bulletIndex) => ( +
  • + ))} +
+ )} + {tip.body && ( +
+ {tip.body} +
+ )} +
+ ))} + {content && ( +
+ {content} +
+ )} + + ) : ( + content + )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index c4f144bfc..5ae6aec9b 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -2,6 +2,8 @@ import React, { createContext, useContext, useMemo, useRef } from 'react'; import { Paper, Text, Stack, Box, Flex } from '@mantine/core'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { Tooltip } from '../../shared/Tooltip'; +import { TooltipTip } from '../../shared/tooltip/TooltipContent'; interface ToolStepContextType { visibleStepCount: number; @@ -20,8 +22,48 @@ export interface ToolStepProps { completedMessage?: string; helpText?: string; showNumber?: boolean; + tooltip?: { + content?: React.ReactNode; + tips?: TooltipTip[]; + header?: { + title: string; + logo?: React.ReactNode; + }; + }; } +const renderTooltipTitle = ( + title: string, + tooltip: ToolStepProps['tooltip'], + isCollapsed: boolean +) => { + if (tooltip && !isCollapsed) { + return ( + + e.stopPropagation()}> + + {title} + + + gpp_maybe + + + + ); + } + + return ( + + {title} + + ); +}; + const ToolStep = ({ title, isVisible = true, @@ -31,7 +73,8 @@ const ToolStep = ({ children, completedMessage, helpText, - showNumber + showNumber, + tooltip }: ToolStepProps) => { if (!isVisible) return null; @@ -70,9 +113,7 @@ const ToolStep = ({ {stepNumber} )} - - {title} - + {renderTooltipTitle(title, tooltip, isCollapsed)} {isCollapsed ? ( diff --git a/frontend/src/components/tooltips/CompressTips.ts b/frontend/src/components/tooltips/CompressTips.ts new file mode 100644 index 000000000..2fb2a0777 --- /dev/null +++ b/frontend/src/components/tooltips/CompressTips.ts @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const CompressTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("compress.tooltip.header.title", "Compress Settings Overview") + }, + tips: [ + { + title: t("compress.tooltip.description.title", "Description"), + description: t("compress.tooltip.description.text", "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually.") + }, + { + title: t("compress.tooltip.qualityAdjustment.title", "Quality Adjustment"), + description: t("compress.tooltip.qualityAdjustment.text", "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity."), + bullets: [ + t("compress.tooltip.qualityAdjustment.bullet1", "Lower values preserve quality"), + t("compress.tooltip.qualityAdjustment.bullet2", "Higher values reduce file size") + ] + }, + { + title: t("compress.tooltip.grayscale.title", "Grayscale"), + description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.") + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/components/tooltips/OCRTips.ts b/frontend/src/components/tooltips/OCRTips.ts new file mode 100644 index 000000000..1002182f2 --- /dev/null +++ b/frontend/src/components/tooltips/OCRTips.ts @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const OcrTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("ocr.tooltip.header.title", "OCR Settings Overview"), + }, + tips: [ + { + title: t("ocr.tooltip.mode.title", "OCR Mode"), + description: t("ocr.tooltip.mode.text", "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight."), + bullets: [ + t("ocr.tooltip.mode.bullet1", "Auto skips pages that already contain text layers."), + t("ocr.tooltip.mode.bullet2", "Force re-OCRs every page and replaces all the text."), + t("ocr.tooltip.mode.bullet3", "Strict halts if any selectable text is found.") + ] + }, + { + title: t("ocr.tooltip.languages.title", "Languages"), + description: t("ocr.tooltip.languages.text", "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection.") + }, + { + title: t("ocr.tooltip.output.title", "Output"), + description: t("ocr.tooltip.output.text", "Decide how you want the text output formatted:"), + bullets: [ + t("ocr.tooltip.output.bullet1", "Searchable PDF embeds text behind the original image."), + t("ocr.tooltip.output.bullet2", "HOCR XML returns a structured machine-readable file."), + t("ocr.tooltip.output.bullet3", "Plain-text sidecar creates a separate .txt file with raw content.") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/contexts/SidebarContext.tsx b/frontend/src/contexts/SidebarContext.tsx new file mode 100644 index 000000000..f09815c5c --- /dev/null +++ b/frontend/src/contexts/SidebarContext.tsx @@ -0,0 +1,47 @@ +import React, { createContext, useContext, useState, useRef } from 'react'; +import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar'; + +const SidebarContext = createContext(undefined); + +export function SidebarProvider({ children }: SidebarProviderProps) { + // All sidebar state management + const quickAccessRef = useRef(null); + const toolPanelRef = useRef(null); + + const [sidebarsVisible, setSidebarsVisible] = useState(true); + const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); + const [readerMode, setReaderMode] = useState(false); + + const sidebarState: SidebarState = { + sidebarsVisible, + leftPanelView, + readerMode, + }; + + const sidebarRefs: SidebarRefs = { + quickAccessRef, + toolPanelRef, + }; + + const contextValue: SidebarContextValue = { + sidebarState, + sidebarRefs, + setSidebarsVisible, + setLeftPanelView, + setReaderMode, + }; + + return ( + + {children} + + ); +} + +export function useSidebarContext(): SidebarContextValue { + const context = useContext(SidebarContext); + if (context === undefined) { + throw new Error('useSidebarContext must be used within a SidebarProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/hooks/useTooltipPosition.ts b/frontend/src/hooks/useTooltipPosition.ts new file mode 100644 index 000000000..3651c1d47 --- /dev/null +++ b/frontend/src/hooks/useTooltipPosition.ts @@ -0,0 +1,177 @@ +import { useState, useEffect, useMemo } from 'react'; +import { clamp } from '../utils/genericUtils'; +import { getSidebarInfo } from '../utils/sidebarUtils'; +import { SidebarRefs, SidebarState } from '../types/sidebar'; + +type Position = 'right' | 'left' | 'top' | 'bottom'; + +interface PlacementResult { + top: number; + left: number; +} + +interface PositionState { + coords: { top: number; left: number; arrowOffset: number | null }; + positionReady: boolean; +} + +function place( + triggerRect: DOMRect, + tooltipRect: DOMRect, + position: Position, + offset: number +): PlacementResult { + let top = 0; + let left = 0; + + switch (position) { + case 'right': + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; + left = triggerRect.right + offset; + break; + case 'left': + top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; + left = triggerRect.left - tooltipRect.width - offset; + break; + case 'top': + top = triggerRect.top - tooltipRect.height - offset; + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; + break; + case 'bottom': + top = triggerRect.bottom + offset; + left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; + break; + } + + return { top, left }; +} + +export function useTooltipPosition({ + open, + sidebarTooltip, + position, + gap, + triggerRef, + tooltipRef, + sidebarRefs, + sidebarState +}: { + open: boolean; + sidebarTooltip: boolean; + position: Position; + gap: number; + triggerRef: React.RefObject; + tooltipRef: React.RefObject; + sidebarRefs?: SidebarRefs; + sidebarState?: SidebarState; +}): PositionState { + const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({ + top: 0, + left: 0, + arrowOffset: null + }); + const [positionReady, setPositionReady] = useState(false); + + // Fallback sidebar position (only used as last resort) + const sidebarLeft = 240; + + const updatePosition = () => { + if (!triggerRef.current || !open) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + + let top: number; + let left: number; + let arrowOffset: number | null = null; + + if (sidebarTooltip) { + // Require sidebar refs and state for proper positioning + if (!sidebarRefs || !sidebarState) { + console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props'); + setPositionReady(false); + return; + } + + const sidebarInfo = getSidebarInfo(sidebarRefs, sidebarState); + const currentSidebarRight = sidebarInfo.rect ? sidebarInfo.rect.right : sidebarLeft; + + // Only show tooltip if we have the tool panel active + if (!sidebarInfo.isToolPanelActive) { + console.log('🚫 Not showing tooltip - tool panel not active'); + setPositionReady(false); + return; + } + + // Position to the right of active sidebar with 20px gap + left = currentSidebarRight + 20; + top = triggerRect.top; // Align top of tooltip with trigger element + + // Only clamp if we have tooltip dimensions + if (tooltipRef.current) { + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const maxTop = window.innerHeight - tooltipRect.height - 4; + const originalTop = top; + top = clamp(top, 4, maxTop); + + // If tooltip was clamped, adjust arrow position to stay aligned with trigger + if (originalTop !== top) { + arrowOffset = triggerRect.top + triggerRect.height / 2 - top; + } + } + + setCoords({ top, left, arrowOffset }); + setPositionReady(true); + } else { + // Regular tooltip logic + if (!tooltipRef.current) return; + + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const placement = place(triggerRect, tooltipRect, position, gap); + top = placement.top; + left = placement.left; + + // Clamp to viewport + top = clamp(top, 4, window.innerHeight - tooltipRect.height - 4); + left = clamp(left, 4, window.innerWidth - tooltipRect.width - 4); + + // Calculate arrow position to stay aligned with trigger + if (position === 'top' || position === 'bottom') { + // For top/bottom arrows, adjust horizontal position + const triggerCenter = triggerRect.left + triggerRect.width / 2; + const tooltipCenter = left + tooltipRect.width / 2; + if (Math.abs(triggerCenter - tooltipCenter) > 4) { + // Arrow needs adjustment + arrowOffset = triggerCenter - left - 4; // 4px is half arrow width + } + } else { + // For left/right arrows, adjust vertical position + const triggerCenter = triggerRect.top + triggerRect.height / 2; + const tooltipCenter = top + tooltipRect.height / 2; + if (Math.abs(triggerCenter - tooltipCenter) > 4) { + // Arrow needs adjustment + arrowOffset = triggerCenter - top - 4; // 4px is half arrow height + } + } + + setCoords({ top, left, arrowOffset }); + setPositionReady(true); + } + }; + + useEffect(() => { + if (!open) return; + + requestAnimationFrame(updatePosition); + + const handleUpdate = () => requestAnimationFrame(updatePosition); + window.addEventListener('scroll', handleUpdate, true); + window.addEventListener('resize', handleUpdate); + + return () => { + window.removeEventListener('scroll', handleUpdate, true); + window.removeEventListener('resize', handleUpdate); + }; + }, [open, sidebarLeft, position, gap, sidebarTooltip]); + + return { coords, positionReady }; +} \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index cccce7667..94a81ee6d 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,11 +2,13 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from 'react-i18next'; import { useFileContext } from "../contexts/FileContext"; import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; +import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; import { useToolManagement } from "../hooks/useToolManagement"; import { useFileHandler } from "../hooks/useFileHandler"; import { Group, Box, Button } from "@mantine/core"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; import { PageEditorFunctions } from "../types/pageEditor"; +import { SidebarRefs, SidebarState } from "../types/sidebar"; import rainbowStyles from '../styles/rainbow.module.css'; import ToolPicker from "../components/tools/ToolPicker"; @@ -20,9 +22,20 @@ import QuickAccessBar from "../components/shared/QuickAccessBar"; import LandingPage from "../components/shared/LandingPage"; import FileUploadModal from "../components/shared/FileUploadModal"; + function HomePageContent() { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); + const { + sidebarState, + sidebarRefs, + setSidebarsVisible, + setLeftPanelView, + setReaderMode + } = useSidebarContext(); + + const { sidebarsVisible, leftPanelView, readerMode } = sidebarState; + const { quickAccessRef, toolPanelRef } = sidebarRefs; const fileContext = useFileContext(); const { activeFiles, currentView, setCurrentView } = fileContext; @@ -37,9 +50,6 @@ function HomePageContent() { clearToolSelection, } = useToolManagement(); - const [sidebarsVisible, setSidebarsVisible] = useState(true); - const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); - const [readerMode, setReaderMode] = useState(false); const [pageEditorFunctions, setPageEditorFunctions] = useState(null); const [previewFile, setPreviewFile] = useState(null); @@ -92,16 +102,15 @@ function HomePageContent() { > {/* Quick Access Bar */} {/* Left: Tool Picker or Selected Tool Panel */}
- + + + ); } diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 71443411f..1cf3581c4 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -103,6 +103,13 @@ --icon-config-bg: #9CA3AF; --icon-config-color: #FFFFFF; + /* Colors for tooltips */ + --tooltip-title-bg: #DBEFFF; + --tooltip-title-color: #31528E; + --tooltip-header-bg: #31528E; + --tooltip-header-color: white; + --tooltip-border: var(--border-default); + /* Inactive icon colors for light mode */ --icon-inactive-bg: #9CA3AF; --icon-inactive-color: #FFFFFF; @@ -201,6 +208,13 @@ --icon-inactive-bg: #2A2F36; --icon-inactive-color: #6E7581; + /* Dark mode tooltip colors */ + --tooltip-title-bg: #4B525A; + --tooltip-title-color: #fff; + --tooltip-header-bg: var(--bg-raised); + --tooltip-header-color: var(--text-primary); + --tooltip-border: var(--border-default); + --accent-interactive: #ffffff; --text-instruction: #ffffff; --text-brand: var(--color-gray-800); @@ -224,6 +238,7 @@ --drop-shadow-color: rgba(255, 255, 255, 0.08); --drop-shadow-color-strong: rgba(255, 255, 255, 0.04); --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06)) drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04)); + /* Adjust shadows for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index cc0cd5cbc..f4b50b264 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -17,6 +17,7 @@ import CompressSettings from "../components/tools/compress/CompressSettings"; import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters"; import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation"; import { BaseToolProps } from "../types/tool"; +import { CompressTips } from "../components/tooltips/CompressTips"; const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); @@ -25,6 +26,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const compressParams = useCompressParameters(); const compressOperation = useCompressOperation(); + const compressTips = CompressTips(); // Endpoint validation const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf"); @@ -104,6 +106,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isCompleted={settingsCollapsed} onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined} completedMessage={settingsCollapsed ? "Compression completed" : undefined} + tooltip={compressTips} > { const { t } = useTranslation(); @@ -26,6 +27,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const ocrParams = useOCRParameters(); const ocrOperation = useOCROperation(); + const ocrTips = OcrTips(); // Step expansion state management const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files'); @@ -126,6 +128,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { setExpandedStep(expandedStep === 'settings' ? null : 'settings'); }} completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined} + tooltip={ocrTips} > ; + toolPanelRef: React.RefObject; +} + +export interface SidebarInfo { + rect: DOMRect | null; + isToolPanelActive: boolean; + sidebarState: SidebarState; +} + +// Context-related interfaces +export interface SidebarContextValue { + sidebarState: SidebarState; + sidebarRefs: SidebarRefs; + setSidebarsVisible: React.Dispatch>; + setLeftPanelView: React.Dispatch>; + setReaderMode: React.Dispatch>; +} + +export interface SidebarProviderProps { + children: React.ReactNode; +} + +// QuickAccessBar related interfaces +export interface QuickAccessBarProps { + onToolsClick: () => void; + onReaderToggle: () => void; +} + +export interface ButtonConfig { + id: string; + name: string; + icon: React.ReactNode; + tooltip: string; + isRound?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; + onClick: () => void; + type?: 'navigation' | 'modal' | 'action'; +} diff --git a/frontend/src/types/tips.ts b/frontend/src/types/tips.ts new file mode 100644 index 000000000..58519e114 --- /dev/null +++ b/frontend/src/types/tips.ts @@ -0,0 +1,13 @@ +export interface TooltipContent { + header?: { + title: string; + logo?: string | React.ReactNode; + }; + tips?: Array<{ + title?: string; + description?: string; + bullets?: string[]; + body?: React.ReactNode; + }>; + content?: React.ReactNode; +} \ No newline at end of file diff --git a/frontend/src/utils/genericUtils.ts b/frontend/src/utils/genericUtils.ts new file mode 100644 index 000000000..253346292 --- /dev/null +++ b/frontend/src/utils/genericUtils.ts @@ -0,0 +1,42 @@ +/** + * DOM utility functions for common operations + */ + +/** + * Clamps a value between a minimum and maximum + * @param value - The value to clamp + * @param min - The minimum allowed value + * @param max - The maximum allowed value + * @returns The clamped value + */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Safely adds an event listener with proper cleanup + * @param target - The target element or window/document + * @param event - The event type + * @param handler - The event handler function + * @param options - Event listener options + * @returns A cleanup function to remove the listener + */ +export function addEventListenerWithCleanup( + target: EventTarget, + event: string, + handler: EventListener, + options?: boolean | AddEventListenerOptions +): () => void { + target.addEventListener(event, handler, options); + return () => target.removeEventListener(event, handler, options); +} + +/** + * Checks if a click event occurred outside of a specified element + * @param event - The click event + * @param element - The element to check against + * @returns True if the click was outside the element + */ +export function isClickOutside(event: MouseEvent, element: HTMLElement | null): boolean { + return element ? !element.contains(event.target as Node) : true; +} diff --git a/frontend/src/utils/sidebarUtils.ts b/frontend/src/utils/sidebarUtils.ts new file mode 100644 index 000000000..cef144971 --- /dev/null +++ b/frontend/src/utils/sidebarUtils.ts @@ -0,0 +1,34 @@ +import { SidebarRefs, SidebarState, SidebarInfo } from '../types/sidebar'; + +/** + * Gets the All tools sidebar information using React refs and state + * @param refs - Object containing refs to sidebar elements + * @param state - Current sidebar state + * @returns Object containing the sidebar rect and whether the tool panel is active + */ +export function getSidebarInfo(refs: SidebarRefs, state: SidebarState): SidebarInfo { + const { quickAccessRef, toolPanelRef } = refs; + const { sidebarsVisible, readerMode } = state; + + // Determine if tool panel should be active based on state + const isToolPanelActive = sidebarsVisible && !readerMode; + + let rect: DOMRect | null = null; + + if (isToolPanelActive && toolPanelRef.current) { + // Tool panel is expanded: use its rect + rect = toolPanelRef.current.getBoundingClientRect(); + } else if (quickAccessRef.current) { + // Fall back to quick access bar + // This probably isn't needed but if we ever have tooltips or modals that need to be positioned relative to the quick access bar, we can use this + rect = quickAccessRef.current.getBoundingClientRect(); + } + + return { + rect, + isToolPanelActive, + sidebarState: state + }; +} + + \ No newline at end of file From 7e3321ee16ebb74d282dcccad08fa14e2bc9a762 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:15:09 +0100 Subject: [PATCH 26/29] Feature/v2/filemanager (#4121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileManager Component Overview Purpose: Modal component for selecting and managing PDF files with preview capabilities Architecture: - Responsive Layouts: MobileLayout.tsx (stacked) vs DesktopLayout.tsx (3-column) - Central State: FileManagerContext handles file operations, selection, and modal state - File Storage: IndexedDB persistence with thumbnail caching Key Components: - FileSourceButtons: Switch between Recent/Local/Drive sources - FileListArea: Scrollable file grid with search functionality - FilePreview: PDF thumbnails with dynamic shadow stacking (1-2 shadow pages based on file count) - FileDetails: File info card with metadata - CompactFileDetails: Mobile-optimized file info layout File Flow: 1. Users select source → browse/search files → select multiple files → preview with navigation → open in tools 2. Files persist across tool switches via FileContext integration 3. Memory management handles large PDFs (up to 100GB+) ```mermaid graph TD FM[FileManager] --> ML[MobileLayout] FM --> DL[DesktopLayout] ML --> FSB[FileSourceButtons
Recent/Local/Drive] ML --> FLA[FileListArea] ML --> FD[FileDetails] DL --> FSB DL --> FLA DL --> FD FLA --> FLI[FileListItem] FD --> FP[FilePreview] FD --> CFD[CompactFileDetails] ``` --------- Co-authored-by: Connor Yoh --- .../public/locales/en-GB/translation.json | 20 +- frontend/src/components/FileManager.tsx | 168 ++++++++++++ .../fileManagement/StorageStatsCard.tsx | 92 ------- .../fileManager/CompactFileDetails.tsx | 126 +++++++++ .../components/fileManager/DesktopLayout.tsx | 89 ++++++ .../components/fileManager/DragOverlay.tsx | 44 +++ .../components/fileManager/FileDetails.tsx | 116 ++++++++ .../components/fileManager/FileInfoCard.tsx | 67 +++++ .../components/fileManager/FileListArea.tsx | 80 ++++++ .../components/fileManager/FileListItem.tsx | 84 ++++++ .../components/fileManager/FilePreview.tsx | 156 +++++++++++ .../fileManager/FileSourceButtons.tsx | 103 +++++++ .../fileManager/HiddenFileInput.tsx | 20 ++ .../components/fileManager/MobileLayout.tsx | 83 ++++++ .../components/fileManager/SearchInput.tsx | 33 +++ .../{fileManagement => shared}/FileCard.tsx | 1 + frontend/src/components/shared/FileGrid.tsx | 2 +- .../src/components/shared/FileUploadModal.tsx | 36 --- .../components/shared/FileUploadSelector.tsx | 255 ------------------ frontend/src/contexts/FileContext.tsx | 28 +- frontend/src/contexts/FileManagerContext.tsx | 218 +++++++++++++++ frontend/src/contexts/FilesModalContext.tsx | 55 +++- .../tools/convert/useConvertOperation.ts | 5 +- frontend/src/hooks/useFileManager.ts | 17 +- frontend/src/hooks/useFilesModal.ts | 57 ---- frontend/src/hooks/useIndexedDBThumbnail.ts | 81 ++++-- frontend/src/pages/HomePage.tsx | 4 +- frontend/src/services/fileStorage.ts | 26 ++ frontend/src/styles/theme.css | 6 + .../tests/convert/ConvertIntegration.test.tsx | 55 +++- .../ConvertSmartDetectionIntegration.test.tsx | 28 +- frontend/src/utils/fileUtils.ts | 4 +- frontend/src/utils/thumbnailUtils.ts | 165 +++++++++++- 33 files changed, 1818 insertions(+), 506 deletions(-) create mode 100644 frontend/src/components/FileManager.tsx delete mode 100644 frontend/src/components/fileManagement/StorageStatsCard.tsx create mode 100644 frontend/src/components/fileManager/CompactFileDetails.tsx create mode 100644 frontend/src/components/fileManager/DesktopLayout.tsx create mode 100644 frontend/src/components/fileManager/DragOverlay.tsx create mode 100644 frontend/src/components/fileManager/FileDetails.tsx create mode 100644 frontend/src/components/fileManager/FileInfoCard.tsx create mode 100644 frontend/src/components/fileManager/FileListArea.tsx create mode 100644 frontend/src/components/fileManager/FileListItem.tsx create mode 100644 frontend/src/components/fileManager/FilePreview.tsx create mode 100644 frontend/src/components/fileManager/FileSourceButtons.tsx create mode 100644 frontend/src/components/fileManager/HiddenFileInput.tsx create mode 100644 frontend/src/components/fileManager/MobileLayout.tsx create mode 100644 frontend/src/components/fileManager/SearchInput.tsx rename frontend/src/components/{fileManagement => shared}/FileCard.tsx (99%) delete mode 100644 frontend/src/components/shared/FileUploadModal.tsx delete mode 100644 frontend/src/components/shared/FileUploadSelector.tsx create mode 100644 frontend/src/contexts/FileManagerContext.tsx delete mode 100644 frontend/src/hooks/useFilesModal.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 089562ed6..ed3942172 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1733,7 +1733,25 @@ "storageError": "Storage error occurred", "storageLow": "Storage is running low. Consider removing old files.", "supportMessage": "Powered by browser database storage for unlimited capacity", - "noFileSelected": "No files selected" + "noFileSelected": "No files selected", + "searchFiles": "Search files...", + "recent": "Recent", + "localFiles": "Local Files", + "googleDrive": "Google Drive", + "googleDriveShort": "Drive", + "myFiles": "My Files", + "noRecentFiles": "No recent files found", + "dropFilesHint": "Drop files here to upload", + "googleDriveNotAvailable": "Google Drive integration not available", + "openFiles": "Open Files", + "openFile": "Open File", + "details": "File Details", + "fileName": "Name", + "fileFormat": "Format", + "fileSize": "Size", + "fileVersion": "Version", + "totalSelected": "Total Selected", + "dropFilesHere": "Drop files here" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx new file mode 100644 index 000000000..02f9af5e4 --- /dev/null +++ b/frontend/src/components/FileManager.tsx @@ -0,0 +1,168 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Modal } from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import { FileWithUrl } from '../types/file'; +import { useFileManager } from '../hooks/useFileManager'; +import { useFilesModalContext } from '../contexts/FilesModalContext'; +import { Tool } from '../types/tool'; +import MobileLayout from './fileManager/MobileLayout'; +import DesktopLayout from './fileManager/DesktopLayout'; +import DragOverlay from './fileManager/DragOverlay'; +import { FileManagerProvider } from '../contexts/FileManagerContext'; + +interface FileManagerProps { + selectedTool?: Tool | null; +} + +const FileManager: React.FC = ({ selectedTool }) => { + const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext(); + const [recentFiles, setRecentFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); + + // File management handlers + const isFileSupported = useCallback((fileName: string) => { + if (!selectedTool?.supportedFormats) return true; + const extension = fileName.split('.').pop()?.toLowerCase(); + return selectedTool.supportedFormats.includes(extension || ''); + }, [selectedTool?.supportedFormats]); + + const refreshRecentFiles = useCallback(async () => { + const files = await loadRecentFiles(); + setRecentFiles(files); + }, [loadRecentFiles]); + + const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => { + try { + const fileObjects = await Promise.all( + files.map(async (fileWithUrl) => { + return await convertToFile(fileWithUrl); + }) + ); + onFilesSelect(fileObjects); + } catch (error) { + console.error('Failed to process selected files:', error); + } + }, [convertToFile, onFilesSelect]); + + const handleNewFileUpload = useCallback(async (files: File[]) => { + if (files.length > 0) { + try { + // Files will get IDs assigned through onFilesSelect -> FileContext addFiles + onFilesSelect(files); + await refreshRecentFiles(); + } catch (error) { + console.error('Failed to process dropped files:', error); + } + } + }, [onFilesSelect, refreshRecentFiles]); + + const handleRemoveFileByIndex = useCallback(async (index: number) => { + await handleRemoveFile(index, recentFiles, setRecentFiles); + }, [handleRemoveFile, recentFiles]); + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 1030); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + useEffect(() => { + if (isFilesModalOpen) { + refreshRecentFiles(); + } else { + // Reset state when modal is closed + setIsDragging(false); + } + }, [isFilesModalOpen, refreshRecentFiles]); + + // Cleanup any blob URLs when component unmounts + useEffect(() => { + return () => { + // Clean up blob URLs from recent files + recentFiles.forEach(file => { + if (file.url && file.url.startsWith('blob:')) { + URL.revokeObjectURL(file.url); + } + }); + }; + }, [recentFiles]); + + // Modal size constants for consistent scaling + const modalHeight = '80vh'; + const modalWidth = isMobile ? '100%' : '80vw'; + const modalMaxWidth = isMobile ? '100%' : '1200px'; + const modalMaxHeight = '1200px'; + const modalMinWidth = isMobile ? '320px' : '800px'; + + return ( + +
+ setIsDragging(true)} + onDragLeave={() => setIsDragging(false)} + accept={["*/*"]} + multiple={true} + activateOnClick={false} + style={{ + height: '100%', + width: '100%', + border: 'none', + borderRadius: '30px', + backgroundColor: 'var(--bg-file-manager)' + }} + styles={{ + inner: { pointerEvents: 'all' } + }} + > + + {isMobile ? : } + + + + +
+
+ ); +}; + +export default FileManager; \ No newline at end of file diff --git a/frontend/src/components/fileManagement/StorageStatsCard.tsx b/frontend/src/components/fileManagement/StorageStatsCard.tsx deleted file mode 100644 index 2d2488712..000000000 --- a/frontend/src/components/fileManagement/StorageStatsCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from "react"; -import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import StorageIcon from "@mui/icons-material/Storage"; -import DeleteIcon from "@mui/icons-material/Delete"; -import WarningIcon from "@mui/icons-material/Warning"; -import { StorageStats } from "../../services/fileStorage"; -import { formatFileSize } from "../../utils/fileUtils"; -import { getStorageUsagePercent } from "../../utils/storageUtils"; -import { StorageConfig } from "../../types/file"; - -interface StorageStatsCardProps { - storageStats: StorageStats | null; - filesCount: number; - onClearAll: () => void; - onReloadFiles: () => void; - storageConfig: StorageConfig; -} - -const StorageStatsCard = ({ - storageStats, - filesCount, - onClearAll, - onReloadFiles, - storageConfig, -}: StorageStatsCardProps) => { - const { t } = useTranslation(); - - if (!storageStats) return null; - - const storageUsagePercent = getStorageUsagePercent(storageStats); - const totalUsed = storageStats.totalSize || storageStats.used; - const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100; - const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100; - - return ( - - - - -
- - {t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)} - - 60 ? "yellow" : "blue"} - size="sm" - mt={4} - /> - - - {storageStats.fileCount} files • {t("storage.approximateSize", "Approximate size")} - - - {Math.round(hardLimitPercent)}% used - - - {isNearLimit && ( - - {t("storage.storageFull", "Storage is nearly full. Consider removing some files.")} - - )} -
- - {filesCount > 0 && ( - - )} - - -
-
-
- ); -}; - -export default StorageStatsCard; diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx new file mode 100644 index 000000000..7f7c410b7 --- /dev/null +++ b/frontend/src/components/fileManager/CompactFileDetails.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { useTranslation } from 'react-i18next'; +import { getFileSize } from '../../utils/fileUtils'; +import { FileWithUrl } from '../../types/file'; + +interface CompactFileDetailsProps { + currentFile: FileWithUrl | null; + thumbnail: string | null; + selectedFiles: FileWithUrl[]; + currentFileIndex: number; + numberOfFiles: number; + isAnimating: boolean; + onPrevious: () => void; + onNext: () => void; + onOpenFiles: () => void; +} + +const CompactFileDetails: React.FC = ({ + currentFile, + thumbnail, + selectedFiles, + currentFileIndex, + numberOfFiles, + isAnimating, + onPrevious, + onNext, + onOpenFiles +}) => { + const { t } = useTranslation(); + const hasSelection = selectedFiles.length > 0; + const hasMultipleFiles = numberOfFiles > 1; + + return ( + + {/* Compact mobile layout */} + + {/* Small preview */} + + {currentFile && thumbnail ? ( + {currentFile.name} + ) : currentFile ? ( +
+ +
+ ) : null} +
+ + {/* File info */} + + + {currentFile ? currentFile.name : 'No file selected'} + + + {currentFile ? getFileSize(currentFile) : ''} + {selectedFiles.length > 1 && ` • ${selectedFiles.length} files`} + + {hasMultipleFiles && ( + + {currentFileIndex + 1} of {selectedFiles.length} + + )} + + + {/* Navigation arrows for multiple files */} + {hasMultipleFiles && ( + + + + + + + + + )} +
+ + {/* Action Button */} + +
+ ); +}; + +export default CompactFileDetails; \ No newline at end of file diff --git a/frontend/src/components/fileManager/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx new file mode 100644 index 000000000..be701ff20 --- /dev/null +++ b/frontend/src/components/fileManager/DesktopLayout.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { Grid } from '@mantine/core'; +import FileSourceButtons from './FileSourceButtons'; +import FileDetails from './FileDetails'; +import SearchInput from './SearchInput'; +import FileListArea from './FileListArea'; +import HiddenFileInput from './HiddenFileInput'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +const DesktopLayout: React.FC = () => { + const { + activeSource, + recentFiles, + modalHeight, + } = useFileManagerContext(); + + return ( + + {/* Column 1: File Sources */} + + + + + {/* Column 2: File List */} + +
+ {activeSource === 'recent' && ( +
+ +
+ )} + +
+ 0 ? modalHeight : '100%', + backgroundColor: 'transparent', + border: 'none', + borderRadius: 0 + }} + /> +
+
+
+ + {/* Column 3: File Details */} + +
+ +
+
+ + {/* Hidden file input for local file selection */} + +
+ ); +}; + +export default DesktopLayout; \ No newline at end of file diff --git a/frontend/src/components/fileManager/DragOverlay.tsx b/frontend/src/components/fileManager/DragOverlay.tsx new file mode 100644 index 000000000..976bb940e --- /dev/null +++ b/frontend/src/components/fileManager/DragOverlay.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Stack, Text, useMantineTheme, alpha } from '@mantine/core'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import { useTranslation } from 'react-i18next'; + +interface DragOverlayProps { + isVisible: boolean; +} + +const DragOverlay: React.FC = ({ isVisible }) => { + const { t } = useTranslation(); + const theme = useMantineTheme(); + + if (!isVisible) return null; + + return ( +
+ + + + {t('fileManager.dropFilesHere', 'Drop files here to upload')} + + +
+ ); +}; + +export default DragOverlay; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileDetails.tsx b/frontend/src/components/fileManager/FileDetails.tsx new file mode 100644 index 000000000..9673d06ad --- /dev/null +++ b/frontend/src/components/fileManager/FileDetails.tsx @@ -0,0 +1,116 @@ +import React, { useState, useEffect } from 'react'; +import { Stack, Button } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; +import FilePreview from './FilePreview'; +import FileInfoCard from './FileInfoCard'; +import CompactFileDetails from './CompactFileDetails'; + +interface FileDetailsProps { + compact?: boolean; +} + +const FileDetails: React.FC = ({ + compact = false +}) => { + const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext(); + const { t } = useTranslation(); + const [currentFileIndex, setCurrentFileIndex] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + + // Get the currently displayed file + const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null; + const hasSelection = selectedFiles.length > 0; + const hasMultipleFiles = selectedFiles.length > 1; + + // Use IndexedDB hook for the current file + const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile); + + // Get thumbnail for current file + const getCurrentThumbnail = () => { + return currentThumbnail; + }; + + const handlePrevious = () => { + if (isAnimating) return; + setIsAnimating(true); + setTimeout(() => { + setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1); + setIsAnimating(false); + }, 150); + }; + + const handleNext = () => { + if (isAnimating) return; + setIsAnimating(true); + setTimeout(() => { + setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0); + setIsAnimating(false); + }, 150); + }; + + // Reset index when selection changes + React.useEffect(() => { + if (currentFileIndex >= selectedFiles.length) { + setCurrentFileIndex(0); + } + }, [selectedFiles.length, currentFileIndex]); + + if (compact) { + return ( + + ); + } + + return ( + + {/* Section 1: Thumbnail Preview */} + + + {/* Section 2: File Details */} + + + + + ); +}; + +export default FileDetails; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileInfoCard.tsx b/frontend/src/components/fileManager/FileInfoCard.tsx new file mode 100644 index 000000000..7e69dd2ed --- /dev/null +++ b/frontend/src/components/fileManager/FileInfoCard.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { detectFileExtension, getFileSize } from '../../utils/fileUtils'; +import { FileWithUrl } from '../../types/file'; + +interface FileInfoCardProps { + currentFile: FileWithUrl | null; + modalHeight: string; +} + +const FileInfoCard: React.FC = ({ + currentFile, + modalHeight +}) => { + const { t } = useTranslation(); + + return ( + + + + {t('fileManager.details', 'File Details')} + + + + + + {t('fileManager.fileName', 'Name')} + + {currentFile ? currentFile.name : ''} + + + + + + {t('fileManager.fileFormat', 'Format')} + {currentFile ? ( + + {detectFileExtension(currentFile.name).toUpperCase()} + + ) : ( + + )} + + + + + {t('fileManager.fileSize', 'Size')} + + {currentFile ? getFileSize(currentFile) : ''} + + + + + + {t('fileManager.fileVersion', 'Version')} + + {currentFile ? '1.0' : ''} + + + + + + ); +}; + +export default FileInfoCard; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx new file mode 100644 index 000000000..8e1975137 --- /dev/null +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Center, ScrollArea, Text, Stack } from '@mantine/core'; +import CloudIcon from '@mui/icons-material/Cloud'; +import HistoryIcon from '@mui/icons-material/History'; +import { useTranslation } from 'react-i18next'; +import FileListItem from './FileListItem'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +interface FileListAreaProps { + scrollAreaHeight: string; + scrollAreaStyle?: React.CSSProperties; +} + +const FileListArea: React.FC = ({ + scrollAreaHeight, + scrollAreaStyle = {}, +}) => { + const { + activeSource, + recentFiles, + filteredFiles, + selectedFileIds, + onFileSelect, + onFileRemove, + onFileDoubleClick, + isFileSupported, + } = useFileManagerContext(); + const { t } = useTranslation(); + + if (activeSource === 'recent') { + return ( + + + {recentFiles.length === 0 ? ( +
+ + + {t('fileManager.noRecentFiles', 'No recent files')} + + {t('fileManager.dropFilesHint', 'Drop files anywhere to upload')} + + +
+ ) : ( + filteredFiles.map((file, index) => ( + onFileSelect(file)} + onRemove={() => onFileRemove(index)} + onDoubleClick={() => onFileDoubleClick(file)} + /> + )) + )} +
+
+ ); + } + + // Google Drive placeholder + return ( +
+ + + {t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')} + +
+ ); +}; + +export default FileListArea; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx new file mode 100644 index 000000000..147133009 --- /dev/null +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { getFileSize, getFileDate } from '../../utils/fileUtils'; +import { FileWithUrl } from '../../types/file'; + +interface FileListItemProps { + file: FileWithUrl; + isSelected: boolean; + isSupported: boolean; + onSelect: () => void; + onRemove: () => void; + onDoubleClick?: () => void; + isLast?: boolean; +} + +const FileListItem: React.FC = ({ + file, + isSelected, + isSupported, + onSelect, + onRemove, + onDoubleClick +}) => { + const [isHovered, setIsHovered] = useState(false); + + return ( + <> + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + {}} // Handled by parent onClick + size="sm" + pl="sm" + pr="xs" + styles={{ + input: { + cursor: 'pointer' + } + }} + /> + + + + {file.name} + {getFileSize(file)} • {getFileDate(file)} + + {/* Delete button - fades in/out on hover */} + { e.stopPropagation(); onRemove(); }} + style={{ + opacity: isHovered ? 1 : 0, + transform: isHovered ? 'scale(1)' : 'scale(0.8)', + transition: 'opacity 0.3s ease, transform 0.3s ease', + pointerEvents: isHovered ? 'auto' : 'none' + }} + > + + + + + { } + + ); +}; + +export default FileListItem; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FilePreview.tsx b/frontend/src/components/fileManager/FilePreview.tsx new file mode 100644 index 000000000..deb4cc67b --- /dev/null +++ b/frontend/src/components/fileManager/FilePreview.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { Box, Center, ActionIcon, Image } from '@mantine/core'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { FileWithUrl } from '../../types/file'; + +interface FilePreviewProps { + currentFile: FileWithUrl | null; + thumbnail: string | null; + numberOfFiles: number; + isAnimating: boolean; + modalHeight: string; + onPrevious: () => void; + onNext: () => void; +} + +const FilePreview: React.FC = ({ + currentFile, + thumbnail, + numberOfFiles, + isAnimating, + modalHeight, + onPrevious, + onNext +}) => { + const hasMultipleFiles = numberOfFiles > 1; + // Common style objects + const navigationArrowStyle = { + position: 'absolute' as const, + top: '50%', + transform: 'translateY(-50%)', + zIndex: 10 + }; + + const stackDocumentBaseStyle = { + position: 'absolute' as const, + width: '100%', + height: '100%' + }; + + const animationStyle = { + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + transform: isAnimating ? 'scale(0.95) translateX(1.25rem)' : 'scale(1) translateX(0)', + opacity: isAnimating ? 0.7 : 1 + }; + + const mainDocumentShadow = '0 6px 16px rgba(0, 0, 0, 0.2)'; + const stackDocumentShadows = { + back: '0 2px 8px rgba(0, 0, 0, 0.1)', + middle: '0 3px 10px rgba(0, 0, 0, 0.12)' + }; + + return ( + + + {/* Left Navigation Arrow */} + {hasMultipleFiles && ( + + + + )} + + {/* Document Stack Container */} + + {/* Background documents (stack effect) */} + {/* Show 2 shadow pages for 3+ files */} + {numberOfFiles >= 3 && ( + + )} + + {/* Show 1 shadow page for 2+ files */} + {numberOfFiles >= 2 && ( + + )} + + {/* Main document */} + {currentFile && thumbnail ? ( + {currentFile.name} + ) : currentFile ? ( +
+ +
+ ) : null} +
+ + {/* Right Navigation Arrow */} + {hasMultipleFiles && ( + + + + )} +
+
+ ); +}; + +export default FilePreview; \ No newline at end of file diff --git a/frontend/src/components/fileManager/FileSourceButtons.tsx b/frontend/src/components/fileManager/FileSourceButtons.tsx new file mode 100644 index 000000000..a6870a661 --- /dev/null +++ b/frontend/src/components/fileManager/FileSourceButtons.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Stack, Text, Button, Group } from '@mantine/core'; +import HistoryIcon from '@mui/icons-material/History'; +import FolderIcon from '@mui/icons-material/Folder'; +import CloudIcon from '@mui/icons-material/Cloud'; +import { useTranslation } from 'react-i18next'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +interface FileSourceButtonsProps { + horizontal?: boolean; +} + +const FileSourceButtons: React.FC = ({ + horizontal = false +}) => { + const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext(); + const { t } = useTranslation(); + + const buttonProps = { + variant: (source: string) => activeSource === source ? 'filled' : 'subtle', + getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined, + getStyles: (source: string) => ({ + root: { + backgroundColor: activeSource === source ? undefined : 'transparent', + color: activeSource === source ? 'var(--mantine-color-gray-9)' : 'var(--mantine-color-gray-6)', + border: 'none', + '&:hover': { + backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)' + } + } + }) + }; + + const buttons = ( + <> + + + + + + + ); + + if (horizontal) { + return ( + + {buttons} + + ); + } + + return ( + + + {t('fileManager.myFiles', 'My Files')} + + {buttons} + + ); +}; + +export default FileSourceButtons; \ No newline at end of file diff --git a/frontend/src/components/fileManager/HiddenFileInput.tsx b/frontend/src/components/fileManager/HiddenFileInput.tsx new file mode 100644 index 000000000..6f2834267 --- /dev/null +++ b/frontend/src/components/fileManager/HiddenFileInput.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +const HiddenFileInput: React.FC = () => { + const { fileInputRef, onFileInputChange } = useFileManagerContext(); + + return ( + + ); +}; + +export default HiddenFileInput; \ No newline at end of file diff --git a/frontend/src/components/fileManager/MobileLayout.tsx b/frontend/src/components/fileManager/MobileLayout.tsx new file mode 100644 index 000000000..30d1ad6b9 --- /dev/null +++ b/frontend/src/components/fileManager/MobileLayout.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Stack, Box } from '@mantine/core'; +import FileSourceButtons from './FileSourceButtons'; +import FileDetails from './FileDetails'; +import SearchInput from './SearchInput'; +import FileListArea from './FileListArea'; +import HiddenFileInput from './HiddenFileInput'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +const MobileLayout: React.FC = () => { + const { + activeSource, + selectedFiles, + modalHeight, + } = useFileManagerContext(); + + // Calculate the height more accurately based on actual content + const calculateFileListHeight = () => { + // Base modal height minus padding and gaps + const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding + + // Estimate heights of fixed components + const fileSourceHeight = '3rem'; // FileSourceButtons height + const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height + const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height + const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps + + return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`; + }; + + return ( + + {/* Section 1: File Sources - Fixed at top */} + + + + + + + + + {/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */} + + {activeSource === 'recent' && ( + + + + )} + + + + + + + {/* Hidden file input for local file selection */} + + + ); +}; + +export default MobileLayout; \ No newline at end of file diff --git a/frontend/src/components/fileManager/SearchInput.tsx b/frontend/src/components/fileManager/SearchInput.tsx new file mode 100644 index 000000000..f47da0dca --- /dev/null +++ b/frontend/src/components/fileManager/SearchInput.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { TextInput } from '@mantine/core'; +import SearchIcon from '@mui/icons-material/Search'; +import { useTranslation } from 'react-i18next'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; + +interface SearchInputProps { + style?: React.CSSProperties; +} + +const SearchInput: React.FC = ({ style }) => { + const { t } = useTranslation(); + const { searchTerm, onSearchChange } = useFileManagerContext(); + + return ( + } + value={searchTerm} + onChange={(e) => onSearchChange(e.target.value)} + + style={{ padding: '0.5rem', ...style }} + styles={{ + input: { + border: 'none', + backgroundColor: 'transparent' + } + }} + /> + ); +}; + +export default SearchInput; \ No newline at end of file diff --git a/frontend/src/components/fileManagement/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx similarity index 99% rename from frontend/src/components/fileManagement/FileCard.tsx rename to frontend/src/components/shared/FileCard.tsx index d474a2f63..1b686ddaf 100644 --- a/frontend/src/components/fileManagement/FileCard.tsx +++ b/frontend/src/components/shared/FileCard.tsx @@ -6,6 +6,7 @@ import StorageIcon from "@mui/icons-material/Storage"; import VisibilityIcon from "@mui/icons-material/Visibility"; import EditIcon from "@mui/icons-material/Edit"; +import { FileWithUrl } from "../../types/file"; import { getFileSize, getFileDate } from "../../utils/fileUtils"; import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; import { fileStorage } from "../../services/fileStorage"; diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx index 78b5a8f17..791a8a453 100644 --- a/frontend/src/components/shared/FileGrid.tsx +++ b/frontend/src/components/shared/FileGrid.tsx @@ -3,7 +3,7 @@ import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@manti import { useTranslation } from "react-i18next"; import SearchIcon from "@mui/icons-material/Search"; import SortIcon from "@mui/icons-material/Sort"; -import FileCard from "../fileManagement/FileCard"; +import FileCard from "./FileCard"; import { FileWithUrl } from "../../types/file"; interface FileGridProps { diff --git a/frontend/src/components/shared/FileUploadModal.tsx b/frontend/src/components/shared/FileUploadModal.tsx deleted file mode 100644 index a83e96e62..000000000 --- a/frontend/src/components/shared/FileUploadModal.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { Modal } from '@mantine/core'; -import FileUploadSelector from './FileUploadSelector'; -import { useFilesModalContext } from '../../contexts/FilesModalContext'; -import { Tool } from '../../types/tool'; - -interface FileUploadModalProps { - selectedTool?: Tool | null; -} - -const FileUploadModal: React.FC = ({ selectedTool }) => { - const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext(); - - - return ( - - - - ); -}; - -export default FileUploadModal; \ No newline at end of file diff --git a/frontend/src/components/shared/FileUploadSelector.tsx b/frontend/src/components/shared/FileUploadSelector.tsx deleted file mode 100644 index 3f345f24b..000000000 --- a/frontend/src/components/shared/FileUploadSelector.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { Stack, Button, Text, Center, Box, Divider } from '@mantine/core'; -import { Dropzone } from '@mantine/dropzone'; -import UploadFileIcon from '@mui/icons-material/UploadFile'; -import { useTranslation } from 'react-i18next'; -import { fileStorage } from '../../services/fileStorage'; -import { FileWithUrl } from '../../types/file'; -import { detectFileExtension } from '../../utils/fileUtils'; -import FileGrid from './FileGrid'; -import MultiSelectControls from './MultiSelectControls'; -import { useFileManager } from '../../hooks/useFileManager'; - -interface FileUploadSelectorProps { - // Appearance - title?: string; - subtitle?: string; - showDropzone?: boolean; - - // File handling - sharedFiles?: any[]; - onFileSelect?: (file: File) => void; - onFilesSelect: (files: File[]) => void; - accept?: string[]; - supportedExtensions?: string[]; // Extensions this tool supports (e.g., ['pdf', 'jpg', 'png']) - - // Loading state - loading?: boolean; - disabled?: boolean; - - // Recent files - showRecentFiles?: boolean; - maxRecentFiles?: number; -} - -const FileUploadSelector = ({ - title, - subtitle, - showDropzone = true, - sharedFiles = [], - onFileSelect, - onFilesSelect, - accept = ["application/pdf", "application/zip", "application/x-zip-compressed"], - supportedExtensions = ["pdf"], // Default to PDF only for most tools - loading = false, - disabled = false, - showRecentFiles = true, - maxRecentFiles = 8, -}: FileUploadSelectorProps) => { - const { t } = useTranslation(); - const fileInputRef = useRef(null); - - const [recentFiles, setRecentFiles] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); - - const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager(); - - // Utility function to check if a file extension is supported - const isFileSupported = useCallback((fileName: string): boolean => { - const extension = detectFileExtension(fileName); - return extension ? supportedExtensions.includes(extension) : false; - }, [supportedExtensions]); - - const refreshRecentFiles = useCallback(async () => { - const files = await loadRecentFiles(); - setRecentFiles(files); - }, [loadRecentFiles]); - - const handleNewFileUpload = useCallback(async (uploadedFiles: File[]) => { - if (uploadedFiles.length === 0) return; - - if (showRecentFiles) { - try { - for (const file of uploadedFiles) { - await storeFile(file); - } - refreshRecentFiles(); - } catch (error) { - console.error('Failed to save files to recent:', error); - } - } - - if (onFilesSelect) { - onFilesSelect(uploadedFiles); - } else if (onFileSelect) { - onFileSelect(uploadedFiles[0]); - } - }, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]); - - const handleFileInputChange = useCallback((event: React.ChangeEvent) => { - const files = event.target.files; - if (files && files.length > 0) { - const fileArray = Array.from(files); - console.log('File input change:', fileArray.length, 'files'); - handleNewFileUpload(fileArray); - } - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }, [handleNewFileUpload]); - - const openFileDialog = useCallback(() => { - fileInputRef.current?.click(); - }, []); - - const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => { - try { - const fileObj = await convertToFile(file); - if (onFilesSelect) { - onFilesSelect([fileObj]); - } else if (onFileSelect) { - onFileSelect(fileObj); - } - } catch (error) { - console.error('Failed to load file from recent:', error); - } - }, [onFileSelect, onFilesSelect, convertToFile]); - - const selectionHandlers = createFileSelectionHandlers(selectedFiles, setSelectedFiles); - - const handleSelectedRecentFiles = useCallback(async () => { - if (onFilesSelect) { - await selectionHandlers.selectMultipleFiles(recentFiles, onFilesSelect); - } - }, [recentFiles, onFilesSelect, selectionHandlers]); - - const handleRemoveFileByIndex = useCallback(async (index: number) => { - await handleRemoveFile(index, recentFiles, setRecentFiles); - const file = recentFiles[index]; - setSelectedFiles(prev => prev.filter(id => id !== (file.id || file.name))); - }, [handleRemoveFile, recentFiles]); - - useEffect(() => { - if (showRecentFiles) { - refreshRecentFiles(); - } - }, [showRecentFiles, refreshRecentFiles]); - - // Get default title and subtitle from translations if not provided - const displayTitle = title || t("fileUpload.selectFiles", "Select files"); - const displaySubtitle = subtitle || t("fileUpload.chooseFromStorageMultiple", "Choose files from storage or upload new PDFs"); - - return ( - <> - - {/* Title and description */} - - - - {displayTitle} - - - {displaySubtitle} - - - - {/* Action buttons */} - - - {showDropzone ? ( - -
- - - {t("fileUpload.dropFilesHere", "Drop files here or click to upload")} - - - {accept.includes('application/pdf') && accept.includes('application/zip') - ? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files") - : accept.includes('application/pdf') - ? t("fileUpload.pdfFilesOnly", "PDF files only") - : t("fileUpload.supportedFileTypes", "Supported file types") - } - - -
-
- ) : ( - - - - {/* Manual file input as backup */} - - - )} -
- - {/* Recent Files Section */} - {showRecentFiles && recentFiles.length > 0 && ( - - - - {t("fileUpload.recentFiles", "Recent Files")} - - { - await Promise.all(recentFiles.map(async (file) => { - await fileStorage.deleteFile(file.id || file.name); - })); - setRecentFiles([]); - setSelectedFiles([]); - }} - /> - - { - await Promise.all(recentFiles.map(async (file) => { - await fileStorage.deleteFile(file.id || file.name); - })); - setRecentFiles([]); - setSelectedFiles([]); - }} - /> - - )} -
- - ); -}; - -export default FileUploadSelector; diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 6e8a42fab..f84d2ec8b 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -100,7 +100,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): case 'REMOVE_FILES': const remainingFiles = state.activeFiles.filter(file => { const fileId = getFileId(file); - return !action.payload.includes(fileId); + return !fileId || !action.payload.includes(fileId); }); const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : []; return { @@ -491,26 +491,38 @@ export function FileContextProvider({ }, [cleanupFile]); // Action implementations - const addFiles = useCallback(async (files: File[]) => { + const addFiles = useCallback(async (files: File[]): Promise => { dispatch({ type: 'ADD_FILES', payload: files }); // Auto-save to IndexedDB if persistence enabled if (enablePersistence) { for (const file of files) { try { - // Check if file already has an ID (already in IndexedDB) + // Check if file already has an explicit ID property (already in IndexedDB) const fileId = getFileId(file); if (!fileId) { - // File doesn't have ID, store it and get the ID - const storedFile = await fileStorage.storeFile(file); - // Add the ID to the file object - Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + // File doesn't have explicit ID, store it with thumbnail + try { + // Generate thumbnail for better recent files experience + const thumbnail = await thumbnailGenerationService.generateThumbnail(file); + const storedFile = await fileStorage.storeFile(file, thumbnail); + // Add the ID to the file object + Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + } catch (thumbnailError) { + // If thumbnail generation fails, store without thumbnail + console.warn('Failed to generate thumbnail, storing without:', thumbnailError); + const storedFile = await fileStorage.storeFile(file); + Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + } } } catch (error) { console.error('Failed to store file:', error); } } } + + // Return files with their IDs assigned + return files; }, [enablePersistence]); const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => { @@ -682,7 +694,7 @@ export function FileContextProvider({ const getFileById = useCallback((fileId: string): File | undefined => { return state.activeFiles.find(file => { const actualFileId = getFileId(file); - return actualFileId === fileId; + return actualFileId && actualFileId === fileId; }); }, [state.activeFiles]); diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx new file mode 100644 index 000000000..c7f924e8e --- /dev/null +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -0,0 +1,218 @@ +import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react'; +import { FileWithUrl } from '../types/file'; +import { StoredFile } from '../services/fileStorage'; + +// Type for the context value - now contains everything directly +interface FileManagerContextValue { + // State + activeSource: 'recent' | 'local' | 'drive'; + selectedFileIds: string[]; + searchTerm: string; + selectedFiles: FileWithUrl[]; + filteredFiles: FileWithUrl[]; + fileInputRef: React.RefObject; + + // Handlers + onSourceChange: (source: 'recent' | 'local' | 'drive') => void; + onLocalFileClick: () => void; + onFileSelect: (file: FileWithUrl) => void; + onFileRemove: (index: number) => void; + onFileDoubleClick: (file: FileWithUrl) => void; + onOpenFiles: () => void; + onSearchChange: (value: string) => void; + onFileInputChange: (event: React.ChangeEvent) => void; + + // External props + recentFiles: FileWithUrl[]; + isFileSupported: (fileName: string) => boolean; + modalHeight: string; +} + +// Create the context +const FileManagerContext = createContext(null); + +// Provider component props +interface FileManagerProviderProps { + children: React.ReactNode; + recentFiles: FileWithUrl[]; + onFilesSelected: (files: FileWithUrl[]) => void; + onClose: () => void; + isFileSupported: (fileName: string) => boolean; + isOpen: boolean; + onFileRemove: (index: number) => void; + modalHeight: string; + storeFile: (file: File) => Promise; + refreshRecentFiles: () => Promise; +} + +export const FileManagerProvider: React.FC = ({ + children, + recentFiles, + onFilesSelected, + onClose, + isFileSupported, + isOpen, + onFileRemove, + modalHeight, + storeFile, + refreshRecentFiles, +}) => { + const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent'); + const [selectedFileIds, setSelectedFileIds] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const fileInputRef = useRef(null); + + // Track blob URLs for cleanup + const createdBlobUrls = useRef>(new Set()); + + // Computed values (with null safety) + const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name)); + const filteredFiles = (recentFiles || []).filter(file => + file.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => { + setActiveSource(source); + if (source !== 'recent') { + setSelectedFileIds([]); + setSearchTerm(''); + } + }, []); + + const handleLocalFileClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileSelect = useCallback((file: FileWithUrl) => { + setSelectedFileIds(prev => { + if (prev.includes(file.id)) { + return prev.filter(id => id !== file.id); + } else { + return [...prev, file.id]; + } + }); + }, []); + + const handleFileRemove = useCallback((index: number) => { + const fileToRemove = filteredFiles[index]; + if (fileToRemove) { + setSelectedFileIds(prev => prev.filter(id => id !== fileToRemove.id)); + } + onFileRemove(index); + }, [filteredFiles, onFileRemove]); + + const handleFileDoubleClick = useCallback((file: FileWithUrl) => { + if (isFileSupported(file.name)) { + onFilesSelected([file]); + onClose(); + } + }, [isFileSupported, onFilesSelected, onClose]); + + const handleOpenFiles = useCallback(() => { + if (selectedFiles.length > 0) { + onFilesSelected(selectedFiles); + onClose(); + } + }, [selectedFiles, onFilesSelected, onClose]); + + const handleSearchChange = useCallback((value: string) => { + setSearchTerm(value); + }, []); + + const handleFileInputChange = useCallback(async (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + if (files.length > 0) { + try { + // Create FileWithUrl objects - FileContext will handle storage and ID assignment + const fileWithUrls = files.map(file => { + const url = URL.createObjectURL(file); + createdBlobUrls.current.add(url); + + return { + // No ID assigned here - FileContext will handle storage and ID assignment + name: file.name, + file, + url, + size: file.size, + lastModified: file.lastModified, + }; + }); + + onFilesSelected(fileWithUrls); + await refreshRecentFiles(); + onClose(); + } catch (error) { + console.error('Failed to process selected files:', error); + } + } + event.target.value = ''; + }, [storeFile, onFilesSelected, refreshRecentFiles, onClose]); + + // Cleanup blob URLs when component unmounts + useEffect(() => { + return () => { + // Clean up all created blob URLs + createdBlobUrls.current.forEach(url => { + URL.revokeObjectURL(url); + }); + createdBlobUrls.current.clear(); + }; + }, []); + + // Reset state when modal closes + useEffect(() => { + if (!isOpen) { + setActiveSource('recent'); + setSelectedFileIds([]); + setSearchTerm(''); + } + }, [isOpen]); + + const contextValue: FileManagerContextValue = { + // State + activeSource, + selectedFileIds, + searchTerm, + selectedFiles, + filteredFiles, + fileInputRef, + + // Handlers + onSourceChange: handleSourceChange, + onLocalFileClick: handleLocalFileClick, + onFileSelect: handleFileSelect, + onFileRemove: handleFileRemove, + onFileDoubleClick: handleFileDoubleClick, + onOpenFiles: handleOpenFiles, + onSearchChange: handleSearchChange, + onFileInputChange: handleFileInputChange, + + // External props + recentFiles, + isFileSupported, + modalHeight, + }; + + return ( + + {children} + + ); +}; + +// Custom hook to use the context +export const useFileManagerContext = (): FileManagerContextValue => { + const context = useContext(FileManagerContext); + + if (!context) { + throw new Error( + 'useFileManagerContext must be used within a FileManagerProvider. ' + + 'Make sure you wrap your component with .' + ); + } + + return context; +}; + +// Export the context for advanced use cases +export { FileManagerContext }; \ No newline at end of file diff --git a/frontend/src/contexts/FilesModalContext.tsx b/frontend/src/contexts/FilesModalContext.tsx index 6940ab9e7..788db77bd 100644 --- a/frontend/src/contexts/FilesModalContext.tsx +++ b/frontend/src/contexts/FilesModalContext.tsx @@ -1,21 +1,58 @@ -import React, { createContext, useContext } from 'react'; -import { useFilesModal, UseFilesModalReturn } from '../hooks/useFilesModal'; +import React, { createContext, useContext, useState, useCallback } from 'react'; import { useFileHandler } from '../hooks/useFileHandler'; -interface FilesModalContextType extends UseFilesModalReturn {} +interface FilesModalContextType { + isFilesModalOpen: boolean; + openFilesModal: () => void; + closeFilesModal: () => void; + onFileSelect: (file: File) => void; + onFilesSelect: (files: File[]) => void; + onModalClose: () => void; + setOnModalClose: (callback: () => void) => void; +} const FilesModalContext = createContext(null); export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { addToActiveFiles, addMultipleFiles } = useFileHandler(); - - const filesModal = useFilesModal({ - onFileSelect: addToActiveFiles, - onFilesSelect: addMultipleFiles, - }); + const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); + const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); + + const openFilesModal = useCallback(() => { + setIsFilesModalOpen(true); + }, []); + + const closeFilesModal = useCallback(() => { + setIsFilesModalOpen(false); + onModalClose?.(); + }, [onModalClose]); + + const handleFileSelect = useCallback((file: File) => { + addToActiveFiles(file); + closeFilesModal(); + }, [addToActiveFiles, closeFilesModal]); + + const handleFilesSelect = useCallback((files: File[]) => { + addMultipleFiles(files); + closeFilesModal(); + }, [addMultipleFiles, closeFilesModal]); + + const setModalCloseCallback = useCallback((callback: () => void) => { + setOnModalClose(() => callback); + }, []); + + const contextValue: FilesModalContextType = { + isFilesModalOpen, + openFilesModal, + closeFilesModal, + onFileSelect: handleFileSelect, + onFilesSelect: handleFilesSelect, + onModalClose, + setOnModalClose: setModalCloseCallback, + }; return ( - + {children} ); diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index 3e12ec9e8..ada920e0b 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -358,7 +358,10 @@ export const useConvertOperation = (): ConvertOperationHook => { setDownloadFilename(convertedFile.name); setStatus(t("downloadComplete")); - await processResults(new Blob([convertedFile]), convertedFile.name); + // Update local files state for hook consumers + setFiles([convertedFile]); + + await addFiles([convertedFile]); markOperationApplied(fileId, operationId); } catch (error: any) { console.error(error); diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index d8e776f75..efb6724eb 100644 --- a/frontend/src/hooks/useFileManager.ts +++ b/frontend/src/hooks/useFileManager.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { fileStorage } from '../services/fileStorage'; import { FileWithUrl } from '../types/file'; +import { generateThumbnailForFile } from '../utils/thumbnailUtils'; export const useFileManager = () => { const [loading, setLoading] = useState(false); @@ -63,7 +64,12 @@ export const useFileManager = () => { const storeFile = useCallback(async (file: File) => { try { - const storedFile = await fileStorage.storeFile(file); + // Generate thumbnail for the file + const thumbnail = await generateThumbnailForFile(file); + + // Store file with thumbnail + const storedFile = await fileStorage.storeFile(file, thumbnail); + // Add the ID to the file object Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); return storedFile; @@ -111,12 +117,21 @@ export const useFileManager = () => { }; }, [convertToFile]); + const touchFile = useCallback(async (id: string) => { + try { + await fileStorage.touchFile(id); + } catch (error) { + console.error('Failed to touch file:', error); + } + }, []); + return { loading, convertToFile, loadRecentFiles, handleRemoveFile, storeFile, + touchFile, createFileSelectionHandlers }; }; \ No newline at end of file diff --git a/frontend/src/hooks/useFilesModal.ts b/frontend/src/hooks/useFilesModal.ts deleted file mode 100644 index 49e9f2c5e..000000000 --- a/frontend/src/hooks/useFilesModal.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useState, useCallback } from 'react'; - -export interface UseFilesModalReturn { - isFilesModalOpen: boolean; - openFilesModal: () => void; - closeFilesModal: () => void; - onFileSelect?: (file: File) => void; - onFilesSelect?: (files: File[]) => void; - onModalClose?: () => void; - setOnModalClose: (callback: () => void) => void; -} - -interface UseFilesModalProps { - onFileSelect?: (file: File) => void; - onFilesSelect?: (files: File[]) => void; -} - -export const useFilesModal = ({ - onFileSelect, - onFilesSelect -}: UseFilesModalProps = {}): UseFilesModalReturn => { - const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); - const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); - - const openFilesModal = useCallback(() => { - setIsFilesModalOpen(true); - }, []); - - const closeFilesModal = useCallback(() => { - setIsFilesModalOpen(false); - onModalClose?.(); - }, [onModalClose]); - - const handleFileSelect = useCallback((file: File) => { - onFileSelect?.(file); - closeFilesModal(); - }, [onFileSelect, closeFilesModal]); - - const handleFilesSelect = useCallback((files: File[]) => { - onFilesSelect?.(files); - closeFilesModal(); - }, [onFilesSelect, closeFilesModal]); - - const setModalCloseCallback = useCallback((callback: () => void) => { - setOnModalClose(() => callback); - }, []); - - return { - isFilesModalOpen, - openFilesModal, - closeFilesModal, - onFileSelect: handleFileSelect, - onFilesSelect: handleFilesSelect, - onModalClose, - setOnModalClose: setModalCloseCallback, - }; -}; \ No newline at end of file diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index b8404e5fe..b8b2c669c 100644 --- a/frontend/src/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/hooks/useIndexedDBThumbnail.ts @@ -1,6 +1,22 @@ import { useState, useEffect } from "react"; -import { getDocument } from "pdfjs-dist"; import { FileWithUrl } from "../types/file"; +import { fileStorage } from "../services/fileStorage"; +import { generateThumbnailForFile } from "../utils/thumbnailUtils"; + +/** + * Calculate optimal scale for thumbnail generation + * Ensures high quality while preventing oversized renders + */ +function calculateThumbnailScale(pageViewport: { width: number; height: number }): number { + const maxWidth = 400; // Max thumbnail width + const maxHeight = 600; // Max thumbnail height + + const scaleX = maxWidth / pageViewport.width; + const scaleY = maxHeight / pageViewport.height; + + // Don't upscale, only downscale if needed + return Math.min(scaleX, scaleY, 1.0); +} /** * Hook for IndexedDB-aware thumbnail loading @@ -28,38 +44,55 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): { return; } - // Second priority: for IndexedDB files without stored thumbnails, just use placeholder - if (file.storedInIndexedDB && file.id) { - // Don't generate thumbnails for files loaded from IndexedDB - just use placeholder - setThumb(null); - return; - } - - // Third priority: generate from blob for regular files during upload (small files only) - if (!file.storedInIndexedDB && file.size < 50 * 1024 * 1024 && !generating) { + // Second priority: generate thumbnail for any file type + if (file.size < 100 * 1024 * 1024 && !generating) { setGenerating(true); try { - const arrayBuffer = await file.arrayBuffer(); - const pdf = await getDocument({ data: arrayBuffer }).promise; - const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale: 0.2 }); - const canvas = document.createElement("canvas"); - canvas.width = viewport.width; - canvas.height = viewport.height; - const context = canvas.getContext("2d"); - if (context && !cancelled) { - await page.render({ canvasContext: context, viewport }).promise; - if (!cancelled) setThumb(canvas.toDataURL()); + let fileObject: File; + + // Handle IndexedDB files vs regular File objects + if (file.storedInIndexedDB && file.id) { + // For IndexedDB files, recreate File object from stored data + const storedFile = await fileStorage.getFile(file.id); + if (!storedFile) { + throw new Error('File not found in IndexedDB'); + } + fileObject = new File([storedFile.data], storedFile.name, { + type: storedFile.type, + lastModified: storedFile.lastModified + }); + } else if (file.file) { + // For FileWithUrl objects that have a File object + fileObject = file.file; + } else if (file.id) { + // Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing + const storedFile = await fileStorage.getFile(file.id); + if (!storedFile) { + throw new Error('File not found in IndexedDB and no File object available'); + } + fileObject = new File([storedFile.data], storedFile.name, { + type: storedFile.type, + lastModified: storedFile.lastModified + }); + } else { + throw new Error('File object not available and no ID for IndexedDB lookup'); + } + + // Use the universal thumbnail generator + const thumbnail = await generateThumbnailForFile(fileObject); + if (!cancelled && thumbnail) { + setThumb(thumbnail); + } else if (!cancelled) { + setThumb(null); } - pdf.destroy(); // Clean up memory } catch (error) { - console.warn('Failed to generate thumbnail for regular file', file.name, error); + console.warn('Failed to generate thumbnail for file', file.name, error); if (!cancelled) setThumb(null); } finally { if (!cancelled) setGenerating(false); } } else { - // Large files or files without proper conditions - show placeholder + // Large files - generate placeholder setThumb(null); } } diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 94a81ee6d..b7a352f0f 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -20,7 +20,7 @@ import Viewer from "../components/viewer/Viewer"; import ToolRenderer from "../components/tools/ToolRenderer"; import QuickAccessBar from "../components/shared/QuickAccessBar"; import LandingPage from "../components/shared/LandingPage"; -import FileUploadModal from "../components/shared/FileUploadModal"; +import FileManager from "../components/FileManager"; function HomePageContent() { @@ -279,7 +279,7 @@ function HomePageContent() {
{/* Global Modals */} - + ); } diff --git a/frontend/src/services/fileStorage.ts b/frontend/src/services/fileStorage.ts index 9ba2e7def..5fd5739e8 100644 --- a/frontend/src/services/fileStorage.ts +++ b/frontend/src/services/fileStorage.ts @@ -225,6 +225,32 @@ class FileStorageService { }); } + /** + * Update the lastModified timestamp of a file (for most recently used sorting) + */ + async touchFile(id: string): Promise { + if (!this.db) await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([this.storeName], 'readwrite'); + const store = transaction.objectStore(this.storeName); + + const getRequest = store.get(id); + getRequest.onsuccess = () => { + const file = getRequest.result; + if (file) { + // Update lastModified to current timestamp + file.lastModified = Date.now(); + const updateRequest = store.put(file); + updateRequest.onsuccess = () => resolve(true); + updateRequest.onerror = () => reject(updateRequest.error); + } else { + resolve(false); // File not found + } + }; + getRequest.onerror = () => reject(getRequest.error); + }); + } + /** * Clear all stored files */ diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 1cf3581c4..9ec48bca7 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -74,6 +74,9 @@ --bg-muted: #f3f4f6; --bg-background: #f9fafb; --bg-toolbar: #ffffff; + --bg-file-manager: #F5F6F8; + --bg-file-list: #ffffff; + --btn-open-file: #0A8BFF; --text-primary: #111827; --text-secondary: #4b5563; --text-muted: #6b7280; @@ -175,6 +178,9 @@ --bg-muted: #1F2329; --bg-background: #2A2F36; --bg-toolbar: #272A2E; + --bg-file-manager: #1F2329; + --bg-file-list: #2A2F36; + --btn-open-file: #0A8BFF; --text-primary: #f9fafb; --text-secondary: #d1d5db; --text-muted: #9ca3af; diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index c9a636035..5ac978810 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -23,13 +23,31 @@ import axios from 'axios'; vi.mock('axios'); const mockedAxios = vi.mocked(axios); -// Mock utility modules -vi.mock('../../utils/thumbnailUtils', () => ({ - generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail') +// Mock only essential services that are actually called by the tests +vi.mock('../../services/fileStorage', () => ({ + fileStorage: { + init: vi.fn().mockResolvedValue(undefined), + storeFile: vi.fn().mockImplementation((file, thumbnail) => { + return Promise.resolve({ + id: `mock-id-${file.name}`, + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified, + thumbnail: thumbnail + }); + }), + getAllFileMetadata: vi.fn().mockResolvedValue([]), + cleanup: vi.fn().mockResolvedValue(undefined) + } })); -vi.mock('../../utils/api', () => ({ - makeApiUrl: vi.fn((path: string) => `/api/v1${path}`) +vi.mock('../../services/thumbnailGenerationService', () => ({ + thumbnailGenerationService: { + generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'), + cleanup: vi.fn(), + destroy: vi.fn() + } })); // Create realistic test files @@ -194,7 +212,14 @@ describe('Convert Tool Integration Tests', () => { test('should correctly map image conversion parameters to API call', async () => { const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' }); - mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + status: 200, + headers: { + 'content-type': 'image/jpeg', + 'content-disposition': 'attachment; filename="test_converted.jpg"' + } + }); const { result } = renderHook(() => useConvertOperation(), { wrapper: TestWrapper @@ -472,7 +497,14 @@ describe('Convert Tool Integration Tests', () => { test('should record operation in FileContext', async () => { const mockBlob = new Blob(['fake-data'], { type: 'image/png' }); - mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + status: 200, + headers: { + 'content-type': 'image/png', + 'content-disposition': 'attachment; filename="test_converted.png"' + } + }); const { result } = renderHook(() => useConvertOperation(), { wrapper: TestWrapper @@ -506,7 +538,14 @@ describe('Convert Tool Integration Tests', () => { test('should clean up blob URLs on reset', async () => { const mockBlob = new Blob(['fake-data'], { type: 'image/png' }); - mockedAxios.post.mockResolvedValueOnce({ data: mockBlob }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + status: 200, + headers: { + 'content-type': 'image/png', + 'content-disposition': 'attachment; filename="test_converted.png"' + } + }); const { result } = renderHook(() => useConvertOperation(), { wrapper: TestWrapper diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx index 3fac5b4ba..64aafc488 100644 --- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -18,9 +18,31 @@ import { detectFileExtension } from '../../utils/fileUtils'; vi.mock('axios'); const mockedAxios = vi.mocked(axios); -// Mock utility modules -vi.mock('../../utils/thumbnailUtils', () => ({ - generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail') +// Mock only essential services that are actually called by the tests +vi.mock('../../services/fileStorage', () => ({ + fileStorage: { + init: vi.fn().mockResolvedValue(undefined), + storeFile: vi.fn().mockImplementation((file, thumbnail) => { + return Promise.resolve({ + id: `mock-id-${file.name}`, + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified, + thumbnail: thumbnail + }); + }), + getAllFileMetadata: vi.fn().mockResolvedValue([]), + cleanup: vi.fn().mockResolvedValue(undefined) + } +})); + +vi.mock('../../services/thumbnailGenerationService', () => ({ + thumbnailGenerationService: { + generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'), + cleanup: vi.fn(), + destroy: vi.fn() + } })); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index b42d2f646..682cd9f3c 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -1,8 +1,8 @@ import { FileWithUrl } from "../types/file"; import { StoredFile, fileStorage } from "../services/fileStorage"; -export function getFileId(file: File): string { - return (file as File & { id?: string }).id || file.name; +export function getFileId(file: File): string | null { + return (file as File & { id?: string }).id || null; } /** diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index 35444035a..f4f224044 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -15,19 +15,172 @@ export function calculateScaleFromFileSize(fileSize: number): number { } /** - * Generate thumbnail for a PDF file during upload + * Generate modern placeholder thumbnail with file extension + */ +function generatePlaceholderThumbnail(file: File): string { + const canvas = document.createElement('canvas'); + canvas.width = 120; + canvas.height = 150; + const ctx = canvas.getContext('2d')!; + + // Get file extension for color theming + const extension = file.name.split('.').pop()?.toUpperCase() || 'FILE'; + const colorScheme = getFileTypeColorScheme(extension); + + // Create gradient background + const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); + gradient.addColorStop(0, colorScheme.bgTop); + gradient.addColorStop(1, colorScheme.bgBottom); + + // Rounded rectangle background + drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8); + ctx.fillStyle = gradient; + ctx.fill(); + + // Subtle shadow/border + ctx.strokeStyle = colorScheme.border; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Modern document icon + drawModernDocumentIcon(ctx, canvas.width / 2, 45, colorScheme.icon); + + // Extension badge + drawExtensionBadge(ctx, canvas.width / 2, canvas.height / 2 + 15, extension, colorScheme); + + // File size with subtle styling + const sizeText = formatFileSize(file.size); + ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + ctx.fillStyle = colorScheme.textSecondary; + ctx.textAlign = 'center'; + ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15); + + return canvas.toDataURL(); +} + +/** + * Get color scheme based on file extension + */ +function getFileTypeColorScheme(extension: string) { + const schemes: Record = { + // Documents + 'PDF': { bgTop: '#FF6B6B20', bgBottom: '#FF6B6B10', border: '#FF6B6B40', icon: '#FF6B6B', badge: '#FF6B6B', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'DOC': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'DOCX': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'TXT': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Spreadsheets + 'XLS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'XLSX': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'CSV': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Presentations + 'PPT': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'PPTX': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Archives + 'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + 'RAR': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + '7Z': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, + + // Default + 'DEFAULT': { bgTop: '#74B9FF20', bgBottom: '#74B9FF10', border: '#74B9FF40', icon: '#74B9FF', badge: '#74B9FF', textPrimary: '#FFFFFF', textSecondary: '#666666' } + }; + + return schemes[extension] || schemes['DEFAULT']; +} + +/** + * Draw rounded rectangle + */ +function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +/** + * Draw modern document icon + */ +function drawModernDocumentIcon(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, color: string) { + const size = 24; + ctx.fillStyle = color; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + + // Document body + drawRoundedRect(ctx, centerX - size/2, centerY - size/2, size, size * 1.2, 3); + ctx.fill(); + + // Folded corner + ctx.beginPath(); + ctx.moveTo(centerX + size/2 - 6, centerY - size/2); + ctx.lineTo(centerX + size/2, centerY - size/2 + 6); + ctx.lineTo(centerX + size/2 - 6, centerY - size/2 + 6); + ctx.closePath(); + ctx.fillStyle = '#FFFFFF40'; + ctx.fill(); +} + +/** + * Draw extension badge + */ +function drawExtensionBadge(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, extension: string, colorScheme: any) { + const badgeWidth = Math.max(extension.length * 8 + 16, 40); + const badgeHeight = 22; + + // Badge background + drawRoundedRect(ctx, centerX - badgeWidth/2, centerY - badgeHeight/2, badgeWidth, badgeHeight, 11); + ctx.fillStyle = colorScheme.badge; + ctx.fill(); + + // Badge text + ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + ctx.fillStyle = colorScheme.textPrimary; + ctx.textAlign = 'center'; + ctx.fillText(extension, centerX, centerY + 4); +} + +/** + * Format file size for display + */ +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + + +/** + * Generate thumbnail for any file type * Returns base64 data URL or undefined if generation fails */ export async function generateThumbnailForFile(file: File): Promise { - // Skip thumbnail generation for large files to avoid memory issues - if (file.size >= 50 * 1024 * 1024) { // 50MB limit + // Skip thumbnail generation for very large files to avoid memory issues + if (file.size >= 100 * 1024 * 1024) { // 100MB limit console.log('Skipping thumbnail generation for large file:', file.name); - return undefined; + return generatePlaceholderThumbnail(file); } + // Handle image files - use original file directly + if (file.type.startsWith('image/')) { + return URL.createObjectURL(file); + } + + // Handle PDF files if (!file.type.startsWith('application/pdf')) { - console.warn('File is not a PDF, skipping thumbnail generation:', file.name); - return undefined; + console.log('File is not a PDF or image, generating placeholder:', file.name); + return generatePlaceholderThumbnail(file); } try { From 7735ea7f48689d85e37773e756a9a0443f4c87c0 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 8 Aug 2025 15:50:59 +0100 Subject: [PATCH 27/29] Enforce 2 space indentation in editorconfig (#4149) # Description of Changes Add missing config for JS and TS files to enforce 2 space indentation in `.editorconfig` --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [x] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [x] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [x] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. Co-authored-by: James --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index d45455a7a..9faac4bf7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,7 +24,7 @@ indent_size = 2 insert_final_newline = false trim_trailing_whitespace = false -[*.js] +[{*.js,*.jsx,*.ts,*.tsx}] indent_size = 2 [*.css] From b45d3a43d4fd63b3dc504433cd36c9f808d76aeb Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:56:20 +0100 Subject: [PATCH 28/29] V2 Restructure homepage (#4138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component Extraction & Context Refactor - Summary 🔧 What We Did - Extracted HomePage's 286-line monolithic component into focused parts - Created ToolPanel (105 lines) for tool selection UI - Created Workbench (203 lines) for view management - Created ToolWorkflowContext (220 lines) for centralized state - Reduced HomePage to 60 lines of provider setup - Eliminated all prop drilling - components use contexts directly 🏆 Why This is Good - Maintainability: Each component has single purpose, easy debugging/development - Architecture: Clean separation of concerns, future features easier to add - Code Quality: 105% more lines but organized/purposeful vs tangled spaghetti code --------- Co-authored-by: Connor Yoh Co-authored-by: James Brunton --- frontend/src/components/layout/Workbench.tsx | 160 +++++++++++ .../src/components/shared/QuickAccessBar.tsx | 25 +- frontend/src/components/tools/ToolPanel.tsx | 89 ++++++ frontend/src/components/tools/ToolPicker.tsx | 25 +- frontend/src/contexts/ToolWorkflowContext.tsx | 221 +++++++++++++++ frontend/src/pages/HomePage.tsx | 265 ++---------------- frontend/src/types/sidebar.ts | 6 - 7 files changed, 503 insertions(+), 288 deletions(-) create mode 100644 frontend/src/components/layout/Workbench.tsx create mode 100644 frontend/src/components/tools/ToolPanel.tsx create mode 100644 frontend/src/contexts/ToolWorkflowContext.tsx diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx new file mode 100644 index 000000000..b0c984ee8 --- /dev/null +++ b/frontend/src/components/layout/Workbench.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { Box } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; +import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext'; +import { useFileHandler } from '../../hooks/useFileHandler'; +import { useFileContext } from '../../contexts/FileContext'; + +import TopControls from '../shared/TopControls'; +import FileEditor from '../fileEditor/FileEditor'; +import PageEditor from '../pageEditor/PageEditor'; +import PageEditorControls from '../pageEditor/PageEditorControls'; +import Viewer from '../viewer/Viewer'; +import ToolRenderer from '../tools/ToolRenderer'; +import LandingPage from '../shared/LandingPage'; + +// No props needed - component uses contexts directly +export default function Workbench() { + const { t } = useTranslation(); + const { isRainbowMode } = useRainbowThemeContext(); + + // Use context-based hooks to eliminate all prop drilling + const { activeFiles, currentView, setCurrentView } = useFileContext(); + const { + previewFile, + pageEditorFunctions, + sidebarsVisible, + setPreviewFile, + setPageEditorFunctions, + setSidebarsVisible + } = useWorkbenchState(); + + const { selectedToolKey, selectedTool, handleToolSelect } = useToolSelection(); + const { addToActiveFiles } = useFileHandler(); + + const handlePreviewClose = () => { + setPreviewFile(null); + const previousMode = sessionStorage.getItem('previousMode'); + if (previousMode === 'split') { + // Use context's handleToolSelect which coordinates tool selection and view changes + handleToolSelect('split'); + sessionStorage.removeItem('previousMode'); + } else if (previousMode === 'compress') { + handleToolSelect('compress'); + sessionStorage.removeItem('previousMode'); + } else if (previousMode === 'convert') { + handleToolSelect('convert'); + sessionStorage.removeItem('previousMode'); + } else { + setCurrentView('fileEditor' as any); + } + }; + + const renderMainContent = () => { + if (!activeFiles[0]) { + return ( + + ); + } + + switch (currentView) { + case "fileEditor": + return ( + { + setCurrentView("pageEditor" as any); + }, + onMergeFiles: (filesToMerge) => { + filesToMerge.forEach(addToActiveFiles); + setCurrentView("viewer" as any); + } + })} + /> + ); + + case "viewer": + return ( + + ); + + case "pageEditor": + return ( + <> + + {pageEditorFunctions && ( + + )} + + ); + + default: + // Check if it's a tool view + if (selectedToolKey && selectedTool) { + return ( + + ); + } + return ( + + ); + } + }; + + return ( + + {/* Top Controls */} + + + {/* Main content area */} + + {renderMainContent()} + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index fb27b1c2c..7aed3632b 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -11,20 +11,18 @@ import { useRainbowThemeContext } from "./RainbowThemeProvider"; import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; -import { QuickAccessBarProps, ButtonConfig } from '../../types/sidebar'; +import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import { ButtonConfig } from '../../types/sidebar'; import './QuickAccessBar.css'; function NavHeader({ activeButton, - setActiveButton, - onReaderToggle, - onToolsClick + setActiveButton }: { activeButton: string; setActiveButton: (id: string) => void; - onReaderToggle: () => void; - onToolsClick: () => void; }) { + const { handleReaderToggle, handleBackToTools } = useToolWorkflow(); return ( <>
@@ -60,8 +58,8 @@ function NavHeader({ variant="subtle" onClick={() => { setActiveButton('tools'); - onReaderToggle(); - onToolsClick(); + handleReaderToggle(); + handleBackToTools(); }} style={{ backgroundColor: activeButton === 'tools' ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)', @@ -84,12 +82,11 @@ function NavHeader({ ); } -const QuickAccessBar = forwardRef(({ - onToolsClick, - onReaderToggle, +const QuickAccessBar = forwardRef(({ }, ref) => { const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); + const { handleReaderToggle } = useToolWorkflow(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); @@ -110,7 +107,7 @@ const QuickAccessBar = forwardRef(({ type: 'navigation', onClick: () => { setActiveButton('read'); - onReaderToggle(); + handleReaderToggle(); } }, { @@ -218,9 +215,7 @@ const QuickAccessBar = forwardRef(({
diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx new file mode 100644 index 000000000..1551ea6c9 --- /dev/null +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; +import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext'; +import ToolPicker from './ToolPicker'; +import ToolRenderer from './ToolRenderer'; +import { useSidebarContext } from "../../contexts/SidebarContext"; +import rainbowStyles from '../../styles/rainbow.module.css'; + +// No props needed - component uses context + +export default function ToolPanel() { + const { t } = useTranslation(); + const { isRainbowMode } = useRainbowThemeContext(); + const { sidebarRefs } = useSidebarContext(); + const { toolPanelRef } = sidebarRefs; + + + // Use context-based hooks to eliminate prop drilling + const { + leftPanelView, + isPanelVisible, + searchQuery, + filteredTools, + setSearchQuery, + handleBackToTools + } = useToolPanelState(); + + const { selectedToolKey, handleToolSelect } = useToolSelection(); + const { setPreviewFile } = useWorkbenchState(); + + return ( +
+
+ {/* Search Bar - Always visible at the top */} +
+ setSearchQuery(e.currentTarget.value)} + autoComplete="off" + size="sm" + /> +
+ + {leftPanelView === 'toolPicker' ? ( + // Tool Picker View +
+ +
+ ) : ( + // Selected Tool Content View +
+ {/* Tool content */} +
+ +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index 7b678de98..d392f21b6 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -1,32 +1,21 @@ -import React, { useState } from "react"; -import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core"; +import React from "react"; +import { Box, Text, Stack, Button } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ToolRegistry } from "../../types/tool"; interface ToolPickerProps { selectedToolKey: string | null; onSelect: (id: string) => void; - toolRegistry: ToolRegistry; + /** Pre-filtered tools to display */ + filteredTools: [string, ToolRegistry[string]][]; } -const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => { +const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProps) => { const { t } = useTranslation(); - const [search, setSearch] = useState(""); - - const filteredTools = Object.entries(toolRegistry).filter(([_, { name }]) => - name.toLowerCase().includes(search.toLowerCase()) - ); return ( - - setSearch(e.currentTarget.value)} - mb="md" - autoComplete="off" - /> - + + {filteredTools.length === 0 ? ( {t("toolPicker.noToolsFound", "No tools found")} diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx new file mode 100644 index 000000000..47f42b011 --- /dev/null +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -0,0 +1,221 @@ +/** + * ToolWorkflowContext - Manages tool selection, UI state, and workflow coordination + * Eliminates prop drilling with a single, simple context + */ + +import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react'; +import { useToolManagement } from '../hooks/useToolManagement'; +import { ToolConfiguration } from '../types/tool'; +import { PageEditorFunctions } from '../types/pageEditor'; + +// State interface +interface ToolWorkflowState { + // UI State + sidebarsVisible: boolean; + leftPanelView: 'toolPicker' | 'toolContent'; + readerMode: boolean; + + // File/Preview State + previewFile: File | null; + pageEditorFunctions: PageEditorFunctions | null; + + // Search State + searchQuery: string; +} + +// Actions +type ToolWorkflowAction = + | { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean } + | { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' } + | { type: 'SET_READER_MODE'; payload: boolean } + | { type: 'SET_PREVIEW_FILE'; payload: File | null } + | { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null } + | { type: 'SET_SEARCH_QUERY'; payload: string } + | { type: 'RESET_UI_STATE' }; + +// Initial state +const initialState: ToolWorkflowState = { + sidebarsVisible: true, + leftPanelView: 'toolPicker', + readerMode: false, + previewFile: null, + pageEditorFunctions: null, + searchQuery: '', +}; + +// Reducer +function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState { + switch (action.type) { + case 'SET_SIDEBARS_VISIBLE': + return { ...state, sidebarsVisible: action.payload }; + case 'SET_LEFT_PANEL_VIEW': + return { ...state, leftPanelView: action.payload }; + case 'SET_READER_MODE': + return { ...state, readerMode: action.payload }; + case 'SET_PREVIEW_FILE': + return { ...state, previewFile: action.payload }; + case 'SET_PAGE_EDITOR_FUNCTIONS': + return { ...state, pageEditorFunctions: action.payload }; + case 'SET_SEARCH_QUERY': + return { ...state, searchQuery: action.payload }; + case 'RESET_UI_STATE': + return { ...initialState, searchQuery: state.searchQuery }; // Preserve search + default: + return state; + } +} + +// Context value interface +interface ToolWorkflowContextValue extends ToolWorkflowState { + // Tool management (from hook) + selectedToolKey: string | null; + selectedTool: ToolConfiguration | null; + toolRegistry: any; // From useToolManagement + + // UI Actions + setSidebarsVisible: (visible: boolean) => void; + setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void; + setReaderMode: (mode: boolean) => void; + setPreviewFile: (file: File | null) => void; + setPageEditorFunctions: (functions: PageEditorFunctions | null) => void; + setSearchQuery: (query: string) => void; + + // Tool Actions + selectTool: (toolId: string) => void; + clearToolSelection: () => void; + + // Workflow Actions (compound actions) + handleToolSelect: (toolId: string) => void; + handleBackToTools: () => void; + handleReaderToggle: () => void; + + // Computed values + filteredTools: [string, any][]; // Filtered by search + isPanelVisible: boolean; +} + +const ToolWorkflowContext = createContext(undefined); + +// Provider component +interface ToolWorkflowProviderProps { + children: React.ReactNode; + /** Handler for view changes (passed from parent) */ + onViewChange?: (view: string) => void; +} + +export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) { + const [state, dispatch] = useReducer(toolWorkflowReducer, initialState); + + // Tool management hook + const { + selectedToolKey, + selectedTool, + toolRegistry, + selectTool, + clearToolSelection, + } = useToolManagement(); + + // UI Action creators + const setSidebarsVisible = useCallback((visible: boolean) => { + dispatch({ type: 'SET_SIDEBARS_VISIBLE', payload: visible }); + }, []); + + const setLeftPanelView = useCallback((view: 'toolPicker' | 'toolContent') => { + dispatch({ type: 'SET_LEFT_PANEL_VIEW', payload: view }); + }, []); + + const setReaderMode = useCallback((mode: boolean) => { + dispatch({ type: 'SET_READER_MODE', payload: mode }); + }, []); + + const setPreviewFile = useCallback((file: File | null) => { + dispatch({ type: 'SET_PREVIEW_FILE', payload: file }); + }, []); + + const setPageEditorFunctions = useCallback((functions: PageEditorFunctions | null) => { + dispatch({ type: 'SET_PAGE_EDITOR_FUNCTIONS', payload: functions }); + }, []); + + const setSearchQuery = useCallback((query: string) => { + dispatch({ type: 'SET_SEARCH_QUERY', payload: query }); + }, []); + + // Workflow actions (compound actions that coordinate multiple state changes) + const handleToolSelect = useCallback((toolId: string) => { + selectTool(toolId); + onViewChange?.('fileEditor'); + setLeftPanelView('toolContent'); + setReaderMode(false); + }, [selectTool, onViewChange, setLeftPanelView, setReaderMode]); + + const handleBackToTools = useCallback(() => { + setLeftPanelView('toolPicker'); + setReaderMode(false); + clearToolSelection(); + }, [setLeftPanelView, setReaderMode, clearToolSelection]); + + const handleReaderToggle = useCallback(() => { + setReaderMode(true); + }, [setReaderMode]); + + // Filter tools based on search query + const filteredTools = useMemo(() => { + if (!toolRegistry) return []; + return Object.entries(toolRegistry).filter(([_, { name }]) => + name.toLowerCase().includes(state.searchQuery.toLowerCase()) + ); + }, [toolRegistry, state.searchQuery]); + + const isPanelVisible = useMemo(() => + state.sidebarsVisible && !state.readerMode, + [state.sidebarsVisible, state.readerMode] + ); + + // Simple context value with basic memoization + const contextValue = useMemo((): ToolWorkflowContextValue => ({ + // State + ...state, + selectedToolKey, + selectedTool, + toolRegistry, + + // Actions + setSidebarsVisible, + setLeftPanelView, + setReaderMode, + setPreviewFile, + setPageEditorFunctions, + setSearchQuery, + selectTool, + clearToolSelection, + + // Workflow Actions + handleToolSelect, + handleBackToTools, + handleReaderToggle, + + // Computed + filteredTools, + isPanelVisible, + }), [state, selectedToolKey, selectedTool, toolRegistry, filteredTools, isPanelVisible]); + + return ( + + {children} + + ); +} + +// Custom hook to use the context +export function useToolWorkflow(): ToolWorkflowContextValue { + const context = useContext(ToolWorkflowContext); + if (!context) { + throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider'); + } + return context; +} + +// Convenience exports for specific use cases (optional - components can use useToolWorkflow directly) +export const useToolSelection = useToolWorkflow; +export const useToolPanelState = useToolWorkflow; +export const useWorkbenchState = useToolWorkflow; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index b7a352f0f..e9009282e 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,57 +1,26 @@ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { useTranslation } from 'react-i18next'; +import React, { useEffect } from "react"; import { useFileContext } from "../contexts/FileContext"; import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; +import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext"; +import { Group } from "@mantine/core"; import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; -import { useToolManagement } from "../hooks/useToolManagement"; -import { useFileHandler } from "../hooks/useFileHandler"; -import { Group, Box, Button } from "@mantine/core"; -import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; -import { PageEditorFunctions } from "../types/pageEditor"; -import { SidebarRefs, SidebarState } from "../types/sidebar"; -import rainbowStyles from '../styles/rainbow.module.css'; -import ToolPicker from "../components/tools/ToolPicker"; -import TopControls from "../components/shared/TopControls"; -import FileEditor from "../components/fileEditor/FileEditor"; -import PageEditor from "../components/pageEditor/PageEditor"; -import PageEditorControls from "../components/pageEditor/PageEditorControls"; -import Viewer from "../components/viewer/Viewer"; -import ToolRenderer from "../components/tools/ToolRenderer"; +import ToolPanel from "../components/tools/ToolPanel"; +import Workbench from "../components/layout/Workbench"; import QuickAccessBar from "../components/shared/QuickAccessBar"; -import LandingPage from "../components/shared/LandingPage"; import FileManager from "../components/FileManager"; function HomePageContent() { - const { t } = useTranslation(); - const { isRainbowMode } = useRainbowThemeContext(); const { - sidebarState, sidebarRefs, - setSidebarsVisible, - setLeftPanelView, - setReaderMode } = useSidebarContext(); - const { sidebarsVisible, leftPanelView, readerMode } = sidebarState; - const { quickAccessRef, toolPanelRef } = sidebarRefs; + const { quickAccessRef } = sidebarRefs; - const fileContext = useFileContext(); - const { activeFiles, currentView, setCurrentView } = fileContext; const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); - const { addToActiveFiles } = useFileHandler(); - const { - selectedToolKey, - selectedTool, - toolRegistry, - selectTool, - clearToolSelection, - } = useToolManagement(); - - const [pageEditorFunctions, setPageEditorFunctions] = useState(null); - const [previewFile, setPreviewFile] = useState(null); + const { selectedTool } = useToolSelection(); // Update file selection context when tool changes useEffect(() => { @@ -65,232 +34,30 @@ function HomePageContent() { } }, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]); - - - const handleToolSelect = useCallback( - (id: string) => { - selectTool(id); - setCurrentView('fileEditor'); // Tools use fileEditor view for file selection - setLeftPanelView('toolContent'); - setReaderMode(false); - }, - [selectTool, setCurrentView] - ); - - const handleQuickAccessTools = useCallback(() => { - setLeftPanelView('toolPicker'); - setReaderMode(false); - clearToolSelection(); - }, [clearToolSelection]); - - const handleReaderToggle = useCallback(() => { - setReaderMode(true); - }, [readerMode]); - - const handleViewChange = useCallback((view: string) => { - setCurrentView(view as any); - }, [setCurrentView]); - - - - return ( - {/* Quick Access Bar */} - - {/* Left: Tool Picker or Selected Tool Panel */} -
-
- {leftPanelView === 'toolPicker' ? ( - // Tool Picker View -
- -
- ) : ( - // Selected Tool Content View -
- {/* Back button */} -
- -
- - {/* Tool title */} -
-

{selectedTool?.name}

-
- - {/* Tool content */} -
- -
-
- )} -
-
- - {/* Main View */} - - {/* Top Controls */} - - {/* Main content area */} - - {!activeFiles[0] ? ( - - ) : currentView === "fileEditor" ? ( - { - handleViewChange("pageEditor"); - }, - onMergeFiles: (filesToMerge) => { - filesToMerge.forEach(addToActiveFiles); - handleViewChange("viewer"); - } - })} - /> - ) : currentView === "viewer" ? ( - { - setPreviewFile(null); // Clear preview file - const previousMode = sessionStorage.getItem('previousMode'); - if (previousMode === 'split') { - selectTool('split'); - setCurrentView('split'); - setLeftPanelView('toolContent'); - sessionStorage.removeItem('previousMode'); - } else if (previousMode === 'compress') { - selectTool('compress'); - setCurrentView('compress'); - setLeftPanelView('toolContent'); - sessionStorage.removeItem('previousMode'); - } else if (previousMode === 'convert') { - selectTool('convert'); - setCurrentView('convert'); - setLeftPanelView('toolContent'); - sessionStorage.removeItem('previousMode'); - } else { - setCurrentView('fileEditor'); - } - } - })} - /> - ) : currentView === "pageEditor" ? ( - <> - - {pageEditorFunctions && ( - - )} - - ) : selectedToolKey && selectedTool ? ( - // Fallback: if tool is selected but not in fileEditor view, show tool in main area - - ) : ( - - )} - - - - {/* Global Modals */} + ref={quickAccessRef} /> + +
); } -// Main HomePage component wrapped with FileSelectionProvider export default function HomePage() { + const { setCurrentView } = useFileContext(); return ( - - - + + + + + ); } diff --git a/frontend/src/types/sidebar.ts b/frontend/src/types/sidebar.ts index 60dcd029d..b286f0b82 100644 --- a/frontend/src/types/sidebar.ts +++ b/frontend/src/types/sidebar.ts @@ -28,12 +28,6 @@ export interface SidebarProviderProps { children: React.ReactNode; } -// QuickAccessBar related interfaces -export interface QuickAccessBarProps { - onToolsClick: () => void; - onReaderToggle: () => void; -} - export interface ButtonConfig { id: string; name: string; From 507ad1dc6197a3887c3ec9c051afc8daaf288050 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:01:56 +0100 Subject: [PATCH 29/29] Feature/v2/shared tool hooks (#4134) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Reece Browne --- CLAUDE.md | 80 ++- .../components/tools/split/SplitSettings.tsx | 17 +- .../tools/compress/useCompressOperation.ts | 287 ++--------- .../tools/convert/useConvertOperation.ts | 464 ++++-------------- .../src/hooks/tools/ocr/useOCROperation.ts | 443 ++++------------- .../src/hooks/tools/shared/useToolApiCalls.ts | 86 ++++ .../hooks/tools/shared/useToolOperation.ts | 264 ++++++++++ .../hooks/tools/shared/useToolResources.ts | 114 +++++ .../src/hooks/tools/shared/useToolState.ts | 137 ++++++ .../hooks/tools/split/useSplitOperation.ts | 302 +++--------- .../hooks/tools/split/useSplitParameters.ts | 15 +- .../services/enhancedPDFProcessingService.ts | 5 +- .../tests/convert/ConvertIntegration.test.tsx | 2 +- frontend/src/tools/Split.tsx | 7 +- frontend/src/types/processing.ts | 1 - frontend/src/utils/toolErrorHandler.ts | 33 ++ frontend/src/utils/toolOperationTracker.ts | 28 ++ frontend/src/utils/toolResponseProcessor.ts | 25 + 18 files changed, 1068 insertions(+), 1242 deletions(-) create mode 100644 frontend/src/hooks/tools/shared/useToolApiCalls.ts create mode 100644 frontend/src/hooks/tools/shared/useToolOperation.ts create mode 100644 frontend/src/hooks/tools/shared/useToolResources.ts create mode 100644 frontend/src/hooks/tools/shared/useToolState.ts create mode 100644 frontend/src/utils/toolErrorHandler.ts create mode 100644 frontend/src/utils/toolOperationTracker.ts create mode 100644 frontend/src/utils/toolResponseProcessor.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8bdd7c235..be4e92201 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,12 +59,73 @@ Frontend designed for **stateful document processing**: Without cleanup: browser crashes with memory leaks. #### Tool Development -- **Pattern**: Follow `src/tools/Split.tsx` as reference implementation -- **File Access**: Tools receive `selectedFiles` prop (computed from activeFiles based on user selection) -- **File Selection**: Users select files in FileEditor (tool mode) → stored as IDs → computed to File objects for tools -- **Integration**: All files are part of FileContext ecosystem - automatic memory management and operation tracking -- **Parameters**: Tool parameter handling patterns still being standardized -- **Preview Integration**: Tools can implement preview functionality (see Split tool's thumbnail preview) + +**Architecture**: Modular hook-based system with clear separation of concerns: + +- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook + - Coordinates all tool operations with consistent interface + - Integrates with FileContext for operation tracking + - Handles validation, error handling, and UI state management + +- **Supporting Hooks**: + - **useToolState**: UI state management (loading, progress, error, files) + - **useToolApiCalls**: HTTP requests and file processing + - **useToolResources**: Blob URLs, thumbnails, ZIP downloads + +- **Utilities**: + - **toolErrorHandler**: Standardized error extraction and i18n support + - **toolResponseProcessor**: API response handling (single/zip/custom) + - **toolOperationTracker**: FileContext integration utilities + +**Three Tool Patterns**: + +**Pattern 1: Single-File Tools** (Individual processing) +- Backend processes one file per API call +- Set `multiFileEndpoint: false` +- Examples: Compress, Rotate +```typescript +return useToolOperation({ + operationType: 'compress', + endpoint: '/api/v1/misc/compress-pdf', + buildFormData: (params, file: File) => { /* single file */ }, + multiFileEndpoint: false, + filePrefix: 'compressed_' +}); +``` + +**Pattern 2: Multi-File Tools** (Batch processing) +- Backend accepts `MultipartFile[]` arrays in single API call +- Set `multiFileEndpoint: true` +- Examples: Split, Merge, Overlay +```typescript +return useToolOperation({ + operationType: 'split', + endpoint: '/api/v1/general/split-pages', + buildFormData: (params, files: File[]) => { /* all files */ }, + multiFileEndpoint: true, + filePrefix: 'split_' +}); +``` + +**Pattern 3: Complex Tools** (Custom processing) +- Tools with complex routing logic or non-standard processing +- Provide `customProcessor` for full control +- Examples: Convert, OCR +```typescript +return useToolOperation({ + operationType: 'convert', + customProcessor: async (params, files) => { /* custom logic */ }, + filePrefix: 'converted_' +}); +``` + +**Benefits**: +- **No Timeouts**: Operations run until completion (supports 100GB+ files) +- **Consistent**: All tools follow same pattern and interface +- **Maintainable**: Single responsibility hooks, easy to test and modify +- **i18n Ready**: Built-in internationalization support +- **Type Safe**: Full TypeScript support with generic interfaces +- **Memory Safe**: Automatic resource cleanup and blob URL management ## Architecture Overview @@ -126,7 +187,10 @@ Without cleanup: browser crashes with memory leaks. - **Core Status**: React SPA architecture complete with multi-tool workflow support - **State Management**: FileContext handles all file operations and tool navigation - **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+) -- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference +- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator + - Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources` + - Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker` + - Pattern: Each tool creates focused operation hook, UI consumes state/actions - **Preview System**: Tool results can be previewed without polluting file context (Split tool example) - **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing @@ -141,7 +205,7 @@ Without cleanup: browser crashes with memory leaks. - **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation - **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling - **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code -- **Tool Development**: New tools should follow Split tool pattern (`src/tools/Split.tsx`) +- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`) - **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes - **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation) diff --git a/frontend/src/components/tools/split/SplitSettings.tsx b/frontend/src/components/tools/split/SplitSettings.tsx index 50ca49f20..95e972eaf 100644 --- a/frontend/src/components/tools/split/SplitSettings.tsx +++ b/frontend/src/components/tools/split/SplitSettings.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants'; export interface SplitParameters { + mode: SplitMode | ''; pages: string; hDiv: string; vDiv: string; @@ -15,16 +16,12 @@ export interface SplitParameters { } export interface SplitSettingsProps { - mode: SplitMode | ''; - onModeChange: (mode: SplitMode | '') => void; parameters: SplitParameters; onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void; disabled?: boolean; } const SplitSettings = ({ - mode, - onModeChange, parameters, onParameterChange, disabled = false @@ -125,8 +122,8 @@ const SplitSettings = ({