From 3b826027444995858e40bb05724eb3620e0c9a29 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:40:58 +0100 Subject: [PATCH 01/18] 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 02/18] 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 ( +
+

Opening Swagger UI in a new tab...

+

If it didn't open automatically, click here

+
+ ); +}; + +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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 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 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 14/18] 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 15/18] 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 16/18] 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 17/18] 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 18/18] 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: