diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9afb72a4d..cc5ded896 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,10 @@ "Bash(./gradlew:*)", "Bash(grep:*)", "Bash(cat:*)", - "Bash(find:*)" + "Bash(find:*)", + "Bash(npm test)", + "Bash(npm test:*)", + "Bash(ls:*)" ], "deny": [] } diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index a8f971d53..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 @@ -317,6 +318,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: @@ -385,10 +387,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.`; 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: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f099fad10..b0e51a8bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -130,7 +130,7 @@ jobs: - name: Build frontend run: cd frontend && npm run build - name: Run frontend tests - run: cd frontend && npm test --passWithNoTests --watchAll=false || true + run: cd frontend && npm run test -- --run - name: Upload frontend build artifacts uses: actions/upload-artifact@v4.6.2 with: diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml new file mode 100644 index 000000000..8c3218869 --- /dev/null +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -0,0 +1,188 @@ +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 }} + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest + 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 }} + ${{ 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/ + 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" + SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:3000" + 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 + 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({ 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/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/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/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/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/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/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/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/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: 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: 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/.gitignore b/frontend/.gitignore index 800f3a80c..8b055b7a6 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +playwright-report +test-results \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2124a2b1f..060a51d64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,16 +38,20 @@ "web-vitals": "^2.1.4" }, "devDependencies": { + "@playwright/test": "^1.40.0", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.0", + "@vitest/coverage-v8": "^1.0.0", + "jsdom": "^23.0.0", "license-checker": "^25.0.1", "postcss": "^8.5.3", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "typescript": "^5.8.3", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^1.0.0" } }, "node_modules/@adobe/css-tools": { @@ -81,6 +85,39 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", + "integrity": "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^2.3.1", + "is-potential-custom-element-name": "^1.0.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -380,6 +417,128 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1016,6 +1175,29 @@ "node": ">=18.0.0" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -1415,6 +1597,22 @@ "pako": "^1.0.10" } }, + "node_modules/@playwright/test": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", + "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1712,6 +1910,13 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz", @@ -2240,6 +2445,178 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -2247,6 +2624,32 @@ "devOptional": true, "license": "ISC" }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -2344,6 +2747,16 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2429,6 +2842,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2498,6 +2921,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2566,6 +2999,25 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2582,6 +3034,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2686,6 +3151,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -2746,6 +3218,35 @@ "node-fetch": "^2.6.12" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -2765,12 +3266,47 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2798,6 +3334,13 @@ "node": "*" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", @@ -2811,6 +3354,19 @@ "node": ">=8" } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2871,6 +3427,16 @@ "wrappy": "1" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2920,6 +3486,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3036,6 +3615,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3261,6 +3887,16 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3307,6 +3943,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3432,6 +4081,26 @@ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -3440,6 +4109,30 @@ "void-elements": "3.1.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3454,6 +4147,16 @@ "node": ">= 6" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/i18next": { "version": "25.2.1", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz", @@ -3500,6 +4203,19 @@ "cross-fetch": "4.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -3625,11 +4341,108 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -3645,6 +4458,71 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", + "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^2.0.1", + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4104,6 +4982,23 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -4122,6 +5017,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4150,6 +5055,18 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -4191,6 +5108,20 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4236,6 +5167,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", @@ -4338,6 +5282,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mlly": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", + "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "pathe": "^2.0.1", + "pkg-types": "^1.3.0", + "ufo": "^1.5.4" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4479,6 +5443,35 @@ "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==", "dev": true }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -4512,6 +5505,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", @@ -4541,6 +5550,22 @@ "os-tmpdir": "^1.0.0" } }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -4576,6 +5601,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4586,6 +5624,16 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -4611,6 +5659,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/pdf-lib": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", @@ -4669,6 +5734,72 @@ "node": ">=0.10.0" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -5068,6 +6199,36 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5432,6 +6593,23 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5529,6 +6707,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5574,6 +6759,26 @@ "license": "MIT", "optional": true }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -5584,8 +6789,8 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "devOptional": true, "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -5611,6 +6816,36 @@ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -5738,6 +6973,20 @@ "spdx-ranges": "^2.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -5783,6 +7032,19 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -5795,6 +7057,26 @@ "node": ">=8" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -5842,6 +7124,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -5911,6 +7200,21 @@ "license": "ISC", "optional": true }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/thenby": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/thenby/-/thenby-1.3.4.tgz", @@ -5918,6 +7222,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", @@ -5963,6 +7274,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5976,6 +7307,45 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/treeify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz", @@ -5991,6 +7361,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -6017,6 +7397,13 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -6057,6 +7444,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -6242,6 +7640,519 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.4.5", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", @@ -6270,6 +8181,562 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -6278,12 +8745,105 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-vitals": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", "integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -6319,6 +8879,45 @@ "devOptional": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -6348,6 +8947,19 @@ "engines": { "node": ">= 14.6" } + }, + "node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 4f7284717..4ff3484b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,7 +37,13 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "generate-licenses": "node scripts/generate-licenses.js" + "generate-licenses": "node scripts/generate-licenses.js", + "test": "vitest", + "test:watch": "vitest --watch", + "test:coverage": "vitest --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:install": "playwright install" }, "eslintConfig": { "extends": [ @@ -58,15 +64,19 @@ ] }, "devDependencies": { + "@playwright/test": "^1.40.0", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.0", + "@vitest/coverage-v8": "^1.0.0", + "jsdom": "^23.0.0", "license-checker": "^25.0.1", "postcss": "^8.5.3", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "typescript": "^5.8.3", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^1.0.0" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..26a94f00b --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,75 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './src/tests', + testMatch: '**/*.spec.ts', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:5173', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1920, height: 1080 } + }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); \ No newline at end of file diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 08995c2e0..dbfceddea 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -347,6 +347,10 @@ "title": "Rotate", "desc": "Easily rotate your PDFs." }, + "convert": { + "title": "Convert", + "desc": "Convert files between different formats" + }, "imageToPdf": { "title": "Image to PDF", "desc": "Convert a image (PNG, JPEG, GIF) to PDF." @@ -579,6 +583,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" @@ -675,6 +683,73 @@ "selectAngle": "Select rotation angle (in multiples of 90 degrees):", "submit": "Rotate" }, + "convert": { + "title": "Convert", + "desc": "Convert files between different formats", + "files": "Files", + "selectFilesPlaceholder": "Select files in the main view to get started", + "settings": "Settings", + "conversionCompleted": "Conversion completed", + "results": "Results", + "defaultFilename": "converted_file", + "conversionResults": "Conversion Results", + "convertFrom": "Convert from", + "convertTo": "Convert to", + "sourceFormatPlaceholder": "Source format", + "targetFormatPlaceholder": "Target format", + "selectSourceFormatFirst": "Select a source format first", + "outputOptions": "Output Options", + "pdfOptions": "PDF Options", + "imageOptions": "Image Options", + "colorType": "Colour Type", + "color": "Colour", + "greyscale": "Greyscale", + "blackwhite": "Black & White", + "dpi": "DPI", + "output": "Output", + "single": "Single", + "multiple": "Multiple", + "fitOption": "Fit Option", + "maintainAspectRatio": "Maintain Aspect Ratio", + "fitDocumentToPage": "Fit Document to Page", + "fillPage": "Fill Page", + "autoRotate": "Auto Rotate", + "autoRotateDescription": "Automatically rotate images to better fit the PDF page", + "combineImages": "Combine Images", + "combineImagesDescription": "Combine all images into one PDF, or create separate PDFs for each image", + "webOptions": "Web to PDF Options", + "zoomLevel": "Zoom Level", + "emailOptions": "Email to PDF Options", + "includeAttachments": "Include email attachments", + "maxAttachmentSize": "Maximum attachment size (MB)", + "includeAllRecipients": "Include CC and BCC recipients in header", + "downloadHtml": "Download HTML intermediate file instead of PDF", + "pdfaOptions": "PDF/A Options", + "outputFormat": "Output Format", + "pdfaNote": "PDF/A-1b is more compatible, PDF/A-2b supports more features.", + "pdfaDigitalSignatureWarning": "The PDF contains a digital signature. This will be removed in the next step.", + "fileFormat": "File Format", + "wordDoc": "Word Document", + "wordDocExt": "Word Document (.docx)", + "odtExt": "OpenDocument Text (.odt)", + "pptExt": "PowerPoint (.pptx)", + "odpExt": "OpenDocument Presentation (.odp)", + "txtExt": "Plain Text (.txt)", + "rtfExt": "Rich Text Format (.rtf)", + "selectedFiles": "Selected files", + "noFileSelected": "No file selected. Use the file panel to add files.", + "convertFiles": "Convert Files", + "converting": "Converting...", + "downloadConverted": "Download Converted File", + "errorNoFiles": "Please select at least one file to convert.", + "errorNoFormat": "Please select both source and target formats.", + "errorNotSupported": "Conversion from {{from}} to {{to}} is not supported.", + "images": "Images", + "officeDocs": "Office Documents (Word, Excel, PowerPoint)", + "imagesExt": "Images (JPG, PNG, etc.)", + "markdown": "Markdown", + "textRtf": "Text/RTF" + }, "imageToPdf": { "tags": "conversion,img,jpg,picture,photo" }, @@ -1562,6 +1637,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", @@ -1613,18 +1694,6 @@ "pageEditor": "Page Editor", "fileManager": "File Manager" }, - "fileManager": { - "dragDrop": "Drag & Drop files here", - "clickToUpload": "Click to upload files", - "selectedFiles": "Selected Files", - "clearAll": "Clear All", - "storage": "Storage", - "filesStored": "files stored", - "storageError": "Storage error occurred", - "storageLow": "Storage is running low. Consider removing old files.", - "uploadError": "Failed to upload some files.", - "supportMessage": "Powered by browser database storage for unlimited capacity" - }, "pageEditor": { "title": "Page Editor", "save": "Save Changes", @@ -1696,7 +1765,16 @@ "failedToLoad": "Failed to load file to active set.", "storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.", "clearAll": "Clear All", - "reloadFiles": "Reload Files" + "reloadFiles": "Reload Files", + "dragDrop": "Drag & Drop files here", + "clickToUpload": "Click to upload files", + "selectedFiles": "Selected Files", + "storage": "Storage", + "filesStored": "files stored", + "storageError": "Storage error occurred", + "storageLow": "Storage is running low. Consider removing old files.", + "supportMessage": "Powered by browser database storage for unlimited capacity", + "noFileSelected": "No files selected" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", @@ -1707,4 +1785,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/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index e73175694..56279f8b4 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", @@ -1557,5 +1567,51 @@ "description": "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with." } } + }, + "convert": { + "files": "Files", + "selectFilesPlaceholder": "Select files in the main view to get started", + "settings": "Settings", + "conversionCompleted": "Conversion completed", + "results": "Results", + "defaultFilename": "converted_file", + "conversionResults": "Conversion Results", + "converting": "Converting...", + "convertFiles": "Convert Files", + "downloadConverted": "Download Converted File", + "convertFrom": "Convert from", + "convertTo": "Convert to", + "sourceFormatPlaceholder": "Source format", + "targetFormatPlaceholder": "Target format", + "selectSourceFormatFirst": "Select a source format first", + "imageOptions": "Image Options", + "colorType": "Color Type", + "color": "Color", + "greyscale": "Greyscale", + "blackwhite": "Black & White", + "dpi": "DPI", + "output": "Output", + "single": "Single", + "multiple": "Multiple", + "pdfOptions": "PDF Options", + "fitOption": "Fit Option", + "maintainAspectRatio": "Maintain Aspect Ratio", + "fitDocumentToPage": "Fit Document to Page", + "fillPage": "Fill Page", + "autoRotate": "Auto Rotate", + "autoRotateDescription": "Automatically rotate images to better fit the PDF page", + "combineImages": "Combine Images", + "combineImagesDescription": "Combine all images into one PDF, or create separate PDFs for each image", + "webOptions": "Web to PDF Options", + "zoomLevel": "Zoom Level", + "emailOptions": "Email to PDF Options", + "includeAttachments": "Include email attachments", + "maxAttachmentSize": "Maximum attachment size (MB)", + "includeAllRecipients": "Include CC and BCC recipients in header", + "downloadHtml": "Download HTML intermediate file instead of PDF", + "pdfaOptions": "PDF/A Options", + "outputFormat": "Output Format", + "pdfaNote": "PDF/A-1b is more compatible, PDF/A-2b supports more features.", + "pdfaDigitalSignatureWarning": "The PDF contains a digital signature. This will be removed in the next step." } } \ No newline at end of file diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json new file mode 100644 index 000000000..8dc3e4f90 --- /dev/null +++ b/frontend/public/locales/en/translation.json @@ -0,0 +1,29 @@ +{ + "convert": { + "selectSourceFormat": "Select source file format", + "selectTargetFormat": "Select target file format", + "selectFirst": "Select a source format first", + "imageOptions": "Image Options:", + "emailOptions": "Email Options:", + "colorType": "Color Type", + "dpi": "DPI", + "singleOrMultiple": "Output", + "emailNote": "Email attachments and embedded images will be included" + }, + "common": { + "color": "Color", + "grayscale": "Grayscale", + "blackWhite": "Black & White", + "single": "Single Image", + "multiple": "Multiple Images" + }, + "groups": { + "document": "Document", + "spreadsheet": "Spreadsheet", + "presentation": "Presentation", + "image": "Image", + "web": "Web", + "text": "Text", + "email": "Email" + } +} \ No newline at end of file diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js deleted file mode 100644 index 1f03afeec..000000000 --- a/frontend/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); 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/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 diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 9e0dc2171..ca5f594b8 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -12,6 +12,7 @@ import { FileOperation } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { zipFileService } from '../../services/zipFileService'; +import { detectFileExtension } from '../../utils/fileUtils'; import styles from '../pageEditor/PageEditor.module.css'; import FileThumbnail from '../pageEditor/FileThumbnail'; import DragDropGrid from '../pageEditor/DragDropGrid'; @@ -34,6 +35,7 @@ interface FileEditorProps { toolMode?: boolean; showUpload?: boolean; showBulkActions?: boolean; + supportedExtensions?: string[]; } const FileEditor = ({ @@ -41,10 +43,17 @@ const FileEditor = ({ onMergeFiles, toolMode = false, showUpload = true, - showBulkActions = true + showBulkActions = true, + supportedExtensions = ["pdf"] }: FileEditorProps) => { const { t } = useTranslation(); + // Utility function to check if a file extension is supported + const isFileSupported = useCallback((fileName: string): boolean => { + const extension = detectFileExtension(fileName); + return extension ? supportedExtensions.includes(extension) : false; + }, [supportedExtensions]); + // Get file context const fileContext = useFileContext(); const { @@ -224,49 +233,46 @@ const FileEditor = ({ // Handle PDF files normally allExtractedFiles.push(file); } else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) { - // Handle ZIP files + // Handle ZIP files - only expand if they contain PDFs try { // Validate ZIP file first const validation = await zipFileService.validateZipFile(file); - if (!validation.isValid) { - errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`); - continue; - } - - // Extract PDF files from ZIP - setZipExtractionProgress({ - isExtracting: true, - currentFile: file.name, - progress: 0, - extractedCount: 0, - totalFiles: validation.fileCount - }); - - const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => { + + if (validation.isValid && validation.containsPDFs) { + // ZIP contains PDFs - extract them setZipExtractionProgress({ isExtracting: true, - currentFile: progress.currentFile, - progress: progress.progress, - extractedCount: progress.extractedCount, - totalFiles: progress.totalFiles + currentFile: file.name, + progress: 0, + extractedCount: 0, + totalFiles: validation.fileCount }); - }); - // Reset extraction progress - setZipExtractionProgress({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); + const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => { + setZipExtractionProgress({ + isExtracting: true, + currentFile: progress.currentFile, + progress: progress.progress, + extractedCount: progress.extractedCount, + totalFiles: progress.totalFiles + }); + }); - if (extractionResult.success) { - allExtractedFiles.push(...extractionResult.extractedFiles); - - // Record ZIP extraction operation - const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const operation: FileOperation = { + // Reset extraction progress + setZipExtractionProgress({ + isExtracting: false, + currentFile: '', + progress: 0, + extractedCount: 0, + totalFiles: 0 + }); + + if (extractionResult.success) { + allExtractedFiles.push(...extractionResult.extractedFiles); + + // Record ZIP extraction operation + const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const operation: FileOperation = { id: operationId, type: 'convert', timestamp: Date.now(), @@ -290,8 +296,13 @@ const FileEditor = ({ if (extractionResult.errors.length > 0) { errors.push(...extractionResult.errors); } + } else { + errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); + } } else { - errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); + // ZIP doesn't contain PDFs or is invalid - treat as regular file + console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`); + allExtractedFiles.push(file); } } catch (zipError) { errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`); @@ -304,7 +315,8 @@ const FileEditor = ({ }); } } else { - errors.push(`Unsupported file type: ${file.name} (${file.type})`); + console.log(`Adding none PDF file: ${file.name} (${file.type})`); + allExtractedFiles.push(file); } } @@ -653,46 +665,35 @@ const FileEditor = ({ return ( - - + + + - - - {showBulkActions && !toolMode && ( - <> - - - - - )} - - {/* Load from storage and upload buttons */} - {showUpload && ( - <> - - - - + + - - - )} - + + )} + {files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? ( @@ -804,6 +805,7 @@ const FileEditor = ({ onSplitFile={handleSplitFile} onSetStatus={setStatus} toolMode={toolMode} + isSupported={isFileSupported(file.name)} /> )} renderSplitMarker={(file, index) => ( @@ -853,7 +855,8 @@ const FileEditor = ({ {error} )} - + + ); }; diff --git a/frontend/src/components/fileManagement/FileCard.tsx b/frontend/src/components/fileManagement/FileCard.tsx index f0972356b..d474a2f63 100644 --- a/frontend/src/components/fileManagement/FileCard.tsx +++ b/frontend/src/components/fileManagement/FileCard.tsx @@ -18,9 +18,10 @@ interface FileCardProps { onEdit?: () => void; isSelected?: boolean; onSelect?: () => void; + isSupported?: boolean; // Whether the file format is supported by the current tool } -const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect }: FileCardProps) => { +const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { const { t } = useTranslation(); const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file); const [isHovered, setIsHovered] = useState(false); @@ -35,15 +36,18 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o width: 225, minWidth: 180, maxWidth: 260, - cursor: onDoubleClick ? "pointer" : undefined, + cursor: onDoubleClick && isSupported ? "pointer" : undefined, position: 'relative', border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined, - backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined + backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined, + opacity: isSupported ? 1 : 0.5, + filter: isSupported ? 'none' : 'grayscale(50%)' }} onDoubleClick={onDoubleClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={onSelect} + data-testid="file-card" > )} + {!isSupported && ( + + {t("fileManager.unsupported", "Unsupported")} + + )} + + + ); +}; + +export default LandingPage; \ 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/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 710dc5b4a..50066a06d 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') }, { @@ -151,6 +160,7 @@ const QuickAccessBar = ({ tooltip: 'Automate workflows', size: 'lg', isRound: false, + type: 'navigation', onClick: () => setActiveButton('automate') }, { @@ -160,7 +170,8 @@ const QuickAccessBar = ({ tooltip: 'Manage files', isRound: true, size: 'lg', - onClick: () => setActiveButton('files') + type: 'modal', + onClick: handleFilesButtonClick }, { id: 'activity', @@ -172,6 +183,7 @@ const QuickAccessBar = ({ tooltip: 'View activity and analytics', isRound: true, size: 'lg', + type: 'navigation', onClick: () => setActiveButton('activity') }, { @@ -180,6 +192,7 @@ const QuickAccessBar = ({ icon: , tooltip: 'Configure settings', size: 'lg', + type: 'modal', onClick: () => { setConfigModalOpen(true); } @@ -193,8 +206,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 { @@ -205,7 +226,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)', @@ -260,13 +281,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} @@ -287,30 +309,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/ConvertFromEmailSettings.tsx b/frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx new file mode 100644 index 000000000..59fa824ee --- /dev/null +++ b/frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Stack, Text, NumberInput, Checkbox } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters'; + +interface ConvertFromEmailSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: keyof ConvertParameters, value: any) => void; + disabled?: boolean; +} + +const ConvertFromEmailSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertFromEmailSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t("convert.emailOptions", "Email to PDF Options")}: + + onParameterChange('emailOptions', { + ...parameters.emailOptions, + includeAttachments: event.currentTarget.checked + })} + disabled={disabled} + data-testid="include-attachments-checkbox" + /> + + {parameters.emailOptions.includeAttachments && ( + + {t("convert.maxAttachmentSize", "Maximum attachment size (MB)")}: + onParameterChange('emailOptions', { + ...parameters.emailOptions, + maxAttachmentSizeMB: Number(value) || 10 + })} + min={1} + max={100} + step={1} + disabled={disabled} + data-testid="max-attachment-size-input" + /> + + )} + + onParameterChange('emailOptions', { + ...parameters.emailOptions, + includeAllRecipients: event.currentTarget.checked + })} + disabled={disabled} + data-testid="include-all-recipients-checkbox" + /> + + onParameterChange('emailOptions', { + ...parameters.emailOptions, + downloadHtml: event.currentTarget.checked + })} + disabled={disabled} + data-testid="download-html-checkbox" + /> + + ); +}; + +export default ConvertFromEmailSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/convert/ConvertFromImageSettings.tsx b/frontend/src/components/tools/convert/ConvertFromImageSettings.tsx new file mode 100644 index 000000000..78d2e75a8 --- /dev/null +++ b/frontend/src/components/tools/convert/ConvertFromImageSettings.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { Stack, Text, Select, Switch } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { COLOR_TYPES, FIT_OPTIONS } from "../../../constants/convertConstants"; +import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters"; + +interface ConvertFromImageSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: keyof ConvertParameters, value: any) => void; + disabled?: boolean; +} + +const ConvertFromImageSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertFromImageSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t("convert.pdfOptions", "PDF Options")}: + val && onParameterChange('imageOptions', { + ...parameters.imageOptions, + fitOption: val as typeof FIT_OPTIONS[keyof typeof FIT_OPTIONS] + })} + data={[ + { value: FIT_OPTIONS.MAINTAIN_ASPECT, label: t("convert.maintainAspectRatio", "Maintain Aspect Ratio") }, + { value: FIT_OPTIONS.FIT_PAGE, label: t("convert.fitDocumentToPage", "Fit Document to Page") }, + { value: FIT_OPTIONS.FILL_PAGE, label: t("convert.fillPage", "Fill Page") }, + ]} + disabled={disabled} + /> + + onParameterChange('imageOptions', { + ...parameters.imageOptions, + autoRotate: event.currentTarget.checked + })} + disabled={disabled} + /> + + onParameterChange('imageOptions', { + ...parameters.imageOptions, + combineImages: event.currentTarget.checked + })} + disabled={disabled} + /> + + ); +}; + +export default ConvertFromImageSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx b/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx new file mode 100644 index 000000000..2cb8474a5 --- /dev/null +++ b/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Stack, Text, NumberInput, Slider } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters'; + +interface ConvertFromWebSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: keyof ConvertParameters, value: any) => void; + disabled?: boolean; +} + +const ConvertFromWebSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertFromWebSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t("convert.webOptions", "Web to PDF Options")}: + + + {t("convert.zoomLevel", "Zoom Level")}: + onParameterChange('htmlOptions', { + ...parameters.htmlOptions, + zoomLevel: Number(value) || 1.0 + })} + min={0.1} + max={3.0} + step={0.1} + precision={1} + disabled={disabled} + data-testid="zoom-level-input" + /> + onParameterChange('htmlOptions', { + ...parameters.htmlOptions, + zoomLevel: value + })} + min={0.1} + max={3.0} + step={0.1} + disabled={disabled} + data-testid="zoom-level-slider" + /> + + + ); +}; + +export default ConvertFromWebSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx new file mode 100644 index 000000000..a3051c88f --- /dev/null +++ b/frontend/src/components/tools/convert/ConvertSettings.tsx @@ -0,0 +1,318 @@ +import React, { useMemo } from "react"; +import { Stack, Text, Group, Divider, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import { useTranslation } from "react-i18next"; +import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig"; +import { isImageFormat, isWebFormat } from "../../../utils/convertUtils"; +import { useFileSelectionActions } from "../../../contexts/FileSelectionContext"; +import { useFileContext } from "../../../contexts/FileContext"; +import { detectFileExtension } from "../../../utils/fileUtils"; +import GroupedFormatDropdown from "./GroupedFormatDropdown"; +import ConvertToImageSettings from "./ConvertToImageSettings"; +import ConvertFromImageSettings from "./ConvertFromImageSettings"; +import ConvertFromWebSettings from "./ConvertFromWebSettings"; +import ConvertFromEmailSettings from "./ConvertFromEmailSettings"; +import ConvertToPdfaSettings from "./ConvertToPdfaSettings"; +import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters"; +import { + FROM_FORMAT_OPTIONS, + EXTENSION_TO_ENDPOINT, + COLOR_TYPES, + OUTPUT_OPTIONS, + FIT_OPTIONS +} from "../../../constants/convertConstants"; + +interface ConvertSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: keyof ConvertParameters, value: any) => void; + getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; + selectedFiles: File[]; + disabled?: boolean; +} + +const ConvertSettings = ({ + parameters, + onParameterChange, + getAvailableToExtensions, + selectedFiles, + disabled = false +}: ConvertSettingsProps) => { + const { t } = useTranslation(); + const theme = useMantineTheme(); + const { colorScheme } = useMantineColorScheme(); + const { setSelectedFiles } = useFileSelectionActions(); + const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext(); + + const allEndpoints = useMemo(() => { + const endpoints = new Set(); + Object.values(EXTENSION_TO_ENDPOINT).forEach(toEndpoints => { + Object.values(toEndpoints).forEach(endpoint => { + endpoints.add(endpoint); + }); + }); + return Array.from(endpoints); + }, []); + + const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints); + + const isConversionAvailable = (fromExt: string, toExt: string): boolean => { + const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt]; + if (!endpointKey) return false; + + return endpointStatus[endpointKey] === true; + }; + + // Enhanced FROM options with endpoint availability + const enhancedFromOptions = useMemo(() => { + const baseOptions = FROM_FORMAT_OPTIONS.map(option => { + // Check if this source format has any available conversions + const availableConversions = getAvailableToExtensions(option.value) || []; + const hasAvailableConversions = availableConversions.some(targetOption => + isConversionAvailable(option.value, targetOption.value) + ); + + return { + ...option, + enabled: hasAvailableConversions + }; + }); + + // Add dynamic format option if current selection is a file- format + if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) { + const extension = parameters.fromExtension.replace('file-', ''); + const dynamicOption = { + value: parameters.fromExtension, + label: extension.toUpperCase(), + group: 'File', + enabled: true + }; + + // Add the dynamic option at the beginning + return [dynamicOption, ...baseOptions]; + } + + return baseOptions; + }, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]); + + // Enhanced TO options with endpoint availability + const enhancedToOptions = useMemo(() => { + if (!parameters.fromExtension) return []; + + const availableOptions = getAvailableToExtensions(parameters.fromExtension) || []; + return availableOptions.map(option => ({ + ...option, + enabled: isConversionAvailable(parameters.fromExtension, option.value) + })); + }, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]); + + const resetParametersToDefaults = () => { + onParameterChange('imageOptions', { + colorType: COLOR_TYPES.COLOR, + dpi: 300, + singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE, + fitOption: FIT_OPTIONS.MAINTAIN_ASPECT, + autoRotate: true, + combineImages: true, + }); + onParameterChange('emailOptions', { + includeAttachments: true, + maxAttachmentSizeMB: 10, + downloadHtml: false, + includeAllRecipients: false, + }); + onParameterChange('pdfaOptions', { + outputFormat: 'pdfa-1', + }); + onParameterChange('isSmartDetection', false); + onParameterChange('smartDetectionType', 'none'); + }; + + const setAutoTargetExtension = (fromExtension: string) => { + const availableToOptions = getAvailableToExtensions(fromExtension); + const autoTarget = availableToOptions.length === 1 ? availableToOptions[0].value : ''; + onParameterChange('toExtension', autoTarget); + }; + + const filterFilesByExtension = (extension: string) => { + return activeFiles.filter(file => { + const fileExtension = detectFileExtension(file.name); + + if (extension === 'any') { + return true; + } else if (extension === 'image') { + return isImageFormat(fileExtension); + } else { + return fileExtension === extension; + } + }); + }; + + const updateFileSelection = (files: File[]) => { + setSelectedFiles(files); + const fileIds = files.map(file => (file as any).id || file.name); + setContextSelectedFiles(fileIds); + }; + + const handleFromExtensionChange = (value: string) => { + onParameterChange('fromExtension', value); + setAutoTargetExtension(value); + resetParametersToDefaults(); + + if (activeFiles.length > 0) { + const matchingFiles = filterFilesByExtension(value); + updateFileSelection(matchingFiles); + } else { + updateFileSelection([]); + } + }; + + const handleToExtensionChange = (value: string) => { + onParameterChange('toExtension', value); + onParameterChange('imageOptions', { + colorType: COLOR_TYPES.COLOR, + dpi: 300, + singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE, + fitOption: FIT_OPTIONS.MAINTAIN_ASPECT, + autoRotate: true, + combineImages: true, + }); + onParameterChange('emailOptions', { + includeAttachments: true, + maxAttachmentSizeMB: 10, + downloadHtml: false, + includeAllRecipients: false, + }); + onParameterChange('pdfaOptions', { + outputFormat: 'pdfa-1', + }); + }; + + + return ( + + + {/* Format Selection */} + + + {t("convert.convertFrom", "Convert from")}: + + + + + + + {t("convert.convertTo", "Convert to")}: + + {!parameters.fromExtension ? ( + + + {t("convert.selectSourceFormatFirst", "Select a source format first")} + + + + ) : ( + + )} + + + {/* Format-specific options */} + {isImageFormat(parameters.toExtension) && ( + <> + + + + )} + + + {/* Color options for image to PDF conversion */} + {(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') || + (parameters.isSmartDetection && parameters.smartDetectionType === 'images') ? ( + <> + + + + ) : null} + + {/* Web to PDF options */} + {((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') || + (parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? ( + <> + + + + ) : null} + + {/* Email to PDF options */} + {parameters.fromExtension === 'eml' && parameters.toExtension === 'pdf' && ( + <> + + + + )} + + {/* PDF to PDF/A options */} + {parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa' && ( + <> + + + + )} + + + ); +}; + +export default ConvertSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/convert/ConvertToImageSettings.tsx b/frontend/src/components/tools/convert/ConvertToImageSettings.tsx new file mode 100644 index 000000000..043541672 --- /dev/null +++ b/frontend/src/components/tools/convert/ConvertToImageSettings.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Stack, Text, Select, NumberInput, Group } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { COLOR_TYPES, OUTPUT_OPTIONS } from "../../../constants/convertConstants"; +import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters"; + +interface ConvertToImageSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: keyof ConvertParameters, value: any) => void; + disabled?: boolean; +} + +const ConvertToImageSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertToImageSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t("convert.imageOptions", "Image Options")}: + + val && onParameterChange('imageOptions', { + ...parameters.imageOptions, + singleOrMultiple: val as typeof OUTPUT_OPTIONS[keyof typeof OUTPUT_OPTIONS] + })} + data={[ + { value: OUTPUT_OPTIONS.SINGLE, label: t("convert.single", "Single") }, + { value: OUTPUT_OPTIONS.MULTIPLE, label: t("convert.multiple", "Multiple") }, + ]} + disabled={disabled} + /> + + ); +}; + +export default ConvertToImageSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx new file mode 100644 index 000000000..0422ee780 --- /dev/null +++ b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Stack, Text, Select, Alert } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters'; +import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection'; + +interface ConvertToPdfaSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: keyof ConvertParameters, value: any) => void; + selectedFiles: File[]; + disabled?: boolean; +} + +const ConvertToPdfaSettings = ({ + parameters, + onParameterChange, + selectedFiles, + disabled = false +}: ConvertToPdfaSettingsProps) => { + const { t } = useTranslation(); + const { hasDigitalSignatures, isChecking } = usePdfSignatureDetection(selectedFiles); + + const pdfaFormatOptions = [ + { value: 'pdfa-1', label: 'PDF/A-1b' }, + { value: 'pdfa', label: 'PDF/A-2b' } + ]; + + return ( + + {t("convert.pdfaOptions", "PDF/A Options")}: + + {hasDigitalSignatures && ( + + + {t("convert.pdfaDigitalSignatureWarning", "The PDF contains a digital signature. This will be removed in the next step.")} + + + )} + + + {t("convert.outputFormat", "Output Format")}: + onParameterChange('ocrType', value || 'skip-text')} + data={[ + { value: 'skip-text', label: t('ocr.settings.ocrMode.auto', 'Auto (skip text layers)') }, + { value: 'force-ocr', label: t('ocr.settings.ocrMode.force', 'Force (re-OCR all, replace text)') }, + { value: 'Normal', label: t('ocr.settings.ocrMode.strict', 'Strict (abort if text found)') }, + ]} + disabled={disabled} + /> + + + + onParameterChange('languages', value)} + placeholder={t('ocr.settings.languages.placeholder', 'Select languages')} + disabled={disabled} + label={t('ocr.settings.languages.label', 'Languages')} + /> + + ); +}; + +export default OCRSettings; \ 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 4ac22aa10..c4f144bfc 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -1,5 +1,7 @@ import React, { createContext, useContext, useMemo, useRef } from 'react'; -import { Paper, Text, Stack, Box } from '@mantine/core'; +import { Paper, Text, Stack, Box, Flex } from '@mantine/core'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; interface ToolStepContextType { visibleStepCount: number; @@ -33,29 +35,60 @@ const ToolStep = ({ }: ToolStepProps) => { if (!isVisible) return null; + const parent = useContext(ToolStepContext); + // Auto-detect if we should show numbers based on sibling count const shouldShowNumber = useMemo(() => { if (showNumber !== undefined) return showNumber; - const parent = useContext(ToolStepContext); return parent ? parent.visibleStepCount >= 3 : false; - }, [showNumber]); + }, [showNumber, parent]); - const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1; + const stepNumber = parent?.getStepNumber?.() || 1; return ( - - {shouldShowNumber ? `${stepNumber}. ` : ''}{title} - + {/* Chevron icon to collapse/expand the step */} + + + {shouldShowNumber && ( + + {stepNumber} + + )} + + {title} + + + + {isCollapsed ? ( + + ) : ( + + )} + {isCollapsed ? ( @@ -96,7 +129,7 @@ export const ToolStepContainer = ({ children }: ToolStepContainerProps) => { let count = 0; React.Children.forEach(children, (child) => { if (React.isValidElement(child) && child.type === ToolStep) { - const isVisible = child.props.isVisible !== false; + const isVisible = (child.props as ToolStepProps).isVisible !== false; if (isVisible) count++; } }); diff --git a/frontend/src/constants/convertConstants.ts b/frontend/src/constants/convertConstants.ts new file mode 100644 index 000000000..f371c7081 --- /dev/null +++ b/frontend/src/constants/convertConstants.ts @@ -0,0 +1,149 @@ + +export const COLOR_TYPES = { + COLOR: 'color', + GREYSCALE: 'greyscale', + BLACK_WHITE: 'blackwhite' +} as const; + +export const OUTPUT_OPTIONS = { + SINGLE: 'single', + MULTIPLE: 'multiple' +} as const; + +export const FIT_OPTIONS = { + FIT_PAGE: 'fitDocumentToPage', + MAINTAIN_ASPECT: 'maintainAspectRatio', + FILL_PAGE: 'fillPage' +} as const; + + +export const CONVERSION_ENDPOINTS = { + 'office-pdf': '/api/v1/convert/file/pdf', + 'pdf-image': '/api/v1/convert/pdf/img', + 'image-pdf': '/api/v1/convert/img/pdf', + 'pdf-office-word': '/api/v1/convert/pdf/word', + 'pdf-office-presentation': '/api/v1/convert/pdf/presentation', + 'pdf-office-text': '/api/v1/convert/pdf/text', + 'pdf-csv': '/api/v1/convert/pdf/csv', + 'pdf-markdown': '/api/v1/convert/pdf/markdown', + 'pdf-html': '/api/v1/convert/pdf/html', + 'pdf-xml': '/api/v1/convert/pdf/xml', + 'pdf-pdfa': '/api/v1/convert/pdf/pdfa', + 'html-pdf': '/api/v1/convert/html/pdf', + 'markdown-pdf': '/api/v1/convert/markdown/pdf', + 'eml-pdf': '/api/v1/convert/eml/pdf' +} as const; + +export const ENDPOINT_NAMES = { + 'office-pdf': 'file-to-pdf', + 'pdf-image': 'pdf-to-img', + 'image-pdf': 'img-to-pdf', + 'pdf-office-word': 'pdf-to-word', + 'pdf-office-presentation': 'pdf-to-presentation', + 'pdf-office-text': 'pdf-to-text', + 'pdf-csv': 'pdf-to-csv', + 'pdf-markdown': 'pdf-to-markdown', + 'pdf-html': 'pdf-to-html', + 'pdf-xml': 'pdf-to-xml', + 'pdf-pdfa': 'pdf-to-pdfa', + 'html-pdf': 'html-to-pdf', + 'markdown-pdf': 'markdown-to-pdf', + 'eml-pdf': 'eml-to-pdf' +} as const; + + +// Grouped file extensions for dropdowns +export const FROM_FORMAT_OPTIONS = [ + { value: 'any', label: 'Any', group: 'Multiple Files' }, + { value: 'image', label: 'Images', group: 'Multiple Files' }, + { value: 'pdf', label: 'PDF', group: 'Document' }, + { value: 'docx', label: 'DOCX', group: 'Document' }, + { value: 'doc', label: 'DOC', group: 'Document' }, + { value: 'odt', label: 'ODT', group: 'Document' }, + { value: 'xlsx', label: 'XLSX', group: 'Spreadsheet' }, + { value: 'xls', label: 'XLS', group: 'Spreadsheet' }, + { value: 'ods', label: 'ODS', group: 'Spreadsheet' }, + { value: 'pptx', label: 'PPTX', group: 'Presentation' }, + { value: 'ppt', label: 'PPT', group: 'Presentation' }, + { value: 'odp', label: 'ODP', group: 'Presentation' }, + { value: 'jpg', label: 'JPG', group: 'Image' }, + { value: 'jpeg', label: 'JPEG', group: 'Image' }, + { value: 'png', label: 'PNG', group: 'Image' }, + { value: 'gif', label: 'GIF', group: 'Image' }, + { value: 'bmp', label: 'BMP', group: 'Image' }, + { value: 'tiff', label: 'TIFF', group: 'Image' }, + { value: 'webp', label: 'WEBP', group: 'Image' }, + { value: 'svg', label: 'SVG', group: 'Image' }, + { value: 'html', label: 'HTML', group: 'Web' }, + { value: 'zip', label: 'ZIP', group: 'Web' }, + { value: 'md', label: 'MD', group: 'Text' }, + { value: 'txt', label: 'TXT', group: 'Text' }, + { value: 'rtf', label: 'RTF', group: 'Text' }, + { value: 'eml', label: 'EML', group: 'Email' }, +]; + +export const TO_FORMAT_OPTIONS = [ + { value: 'pdf', label: 'PDF', group: 'Document' }, + { value: 'pdfa', label: 'PDF/A', group: 'Document' }, + { value: 'docx', label: 'DOCX', group: 'Document' }, + { value: 'odt', label: 'ODT', group: 'Document' }, + { value: 'csv', label: 'CSV', group: 'Spreadsheet' }, + { value: 'pptx', label: 'PPTX', group: 'Presentation' }, + { value: 'odp', label: 'ODP', group: 'Presentation' }, + { value: 'txt', label: 'TXT', group: 'Text' }, + { value: 'rtf', label: 'RTF', group: 'Text' }, + { value: 'md', label: 'MD', group: 'Text' }, + { value: 'png', label: 'PNG', group: 'Image' }, + { value: 'jpg', label: 'JPG', group: 'Image' }, + { value: 'gif', label: 'GIF', group: 'Image' }, + { value: 'tiff', label: 'TIFF', group: 'Image' }, + { value: 'bmp', label: 'BMP', group: 'Image' }, + { value: 'webp', label: 'WEBP', group: 'Image' }, + { value: 'html', label: 'HTML', group: 'Web' }, + { value: 'xml', label: 'XML', group: 'Web' }, +]; + +// Conversion matrix - what each source format can convert to +export const CONVERSION_MATRIX: Record = { + '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/contexts/FilesModalContext.tsx b/frontend/src/contexts/FilesModalContext.tsx new file mode 100644 index 000000000..6940ab9e7 --- /dev/null +++ b/frontend/src/contexts/FilesModalContext.tsx @@ -0,0 +1,30 @@ +import React, { createContext, useContext } from 'react'; +import { useFilesModal, UseFilesModalReturn } from '../hooks/useFilesModal'; +import { useFileHandler } from '../hooks/useFileHandler'; + +interface FilesModalContextType extends UseFilesModalReturn {} + +const FilesModalContext = createContext(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/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/tools/ocr/useOCROperation.ts b/frontend/src/hooks/tools/ocr/useOCROperation.ts new file mode 100644 index 000000000..8608ebbdf --- /dev/null +++ b/frontend/src/hooks/tools/ocr/useOCROperation.ts @@ -0,0 +1,372 @@ +import { useState, useCallback } from 'react'; +import axios from 'axios'; +import { useTranslation } from 'react-i18next'; +import { useFileContext } from '../../../contexts/FileContext'; +import { FileOperation } from '../../../types/fileContext'; +import { OCRParameters } from '../../../components/tools/ocr/OCRSettings'; + +//Extract files from a ZIP blob +async function extractZipFile(zipBlob: Blob): Promise { + const JSZip = await import('jszip'); + const zip = new JSZip.default(); + + const arrayBuffer = await zipBlob.arrayBuffer(); + const zipContent = await zip.loadAsync(arrayBuffer); + + const extractedFiles: File[] = []; + + for (const [filename, file] of Object.entries(zipContent.files)) { + if (!file.dir) { + const content = await file.async('blob'); + const extractedFile = new File([content], filename, { type: getMimeType(filename) }); + extractedFiles.push(extractedFile); + } + } + + return extractedFiles; +} + +//Get MIME type based on file extension +function getMimeType(filename: string): string { + const ext = filename.toLowerCase().split('.').pop(); + switch (ext) { + case 'pdf': + return 'application/pdf'; + case 'txt': + return 'text/plain'; + case 'zip': + return 'application/zip'; + default: + return 'application/octet-stream'; + } +} + +export interface OCROperationHook { + files: File[]; + thumbnails: string[]; + downloadUrl: string | null; + downloadFilename: string | null; + isLoading: boolean; + isGeneratingThumbnails: boolean; + status: string; + errorMessage: string | null; + executeOperation: (parameters: OCRParameters, selectedFiles: File[]) => Promise; + resetResults: () => void; + clearError: () => void; +} + +export const useOCROperation = (): OCROperationHook => { + const { t } = useTranslation(); + const { + recordOperation, + markOperationApplied, + markOperationFailed, + addFiles + } = useFileContext(); + + // Internal state management + 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); + + // Track blob URLs for cleanup + const [blobUrls, setBlobUrls] = useState([]); + + const cleanupBlobUrls = useCallback(() => { + blobUrls.forEach(url => { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.warn('Failed to revoke blob URL:', error); + } + }); + setBlobUrls([]); + }, [blobUrls]); + + const buildFormData = useCallback(( + parameters: OCRParameters, + file: File + ) => { + const formData = new FormData(); + + // Add the file + formData.append('fileInput', file); + + // Add languages as multiple parameters with same name (like checkboxes) + parameters.languages.forEach(lang => { + formData.append('languages', lang); + }); + + // Add other parameters + formData.append('ocrType', parameters.ocrType); + formData.append('ocrRenderType', parameters.ocrRenderType); + + // Handle additional options - convert array to individual boolean parameters + formData.append('sidecar', parameters.additionalOptions.includes('sidecar').toString()); + formData.append('deskew', parameters.additionalOptions.includes('deskew').toString()); + formData.append('clean', parameters.additionalOptions.includes('clean').toString()); + formData.append('cleanFinal', parameters.additionalOptions.includes('cleanFinal').toString()); + formData.append('removeImagesAfter', parameters.additionalOptions.includes('removeImagesAfter').toString()); + + const endpoint = '/api/v1/misc/ocr-pdf'; + + return { formData, endpoint }; + }, []); + + const createOperation = useCallback(( + parameters: OCRParameters, + selectedFiles: File[] + ): { operation: FileOperation; operationId: string; fileId: string } => { + const operationId = `ocr-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const fileId = selectedFiles.map(f => f.name).join(','); + + const operation: FileOperation = { + id: operationId, + type: 'ocr', + timestamp: Date.now(), + fileIds: selectedFiles.map(f => f.name), + status: 'pending', + metadata: { + originalFileName: selectedFiles[0]?.name, + parameters: { + languages: parameters.languages, + ocrType: parameters.ocrType, + ocrRenderType: parameters.ocrRenderType, + additionalOptions: parameters.additionalOptions, + }, + fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0) + } + }; + + return { operation, operationId, fileId }; + }, []); + + const executeOperation = useCallback(async ( + parameters: OCRParameters, + selectedFiles: File[] + ) => { + if (selectedFiles.length === 0) { + setStatus(t("noFileSelected") || "No file selected"); + return; + } + + if (parameters.languages.length === 0) { + setErrorMessage('Please select at least one language for OCR processing.'); + return; + } + + const validFiles = selectedFiles.filter(file => file.size > 0); + if (validFiles.length === 0) { + setErrorMessage('No valid files to process. All selected files are empty.'); + return; + } + + if (validFiles.length < selectedFiles.length) { + console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`); + } + + const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); + + recordOperation(fileId, operation); + + setStatus(t("loading") || "Loading..."); + setIsLoading(true); + setErrorMessage(null); + setFiles([]); + setThumbnails([]); + + try { + const processedFiles: File[] = []; + const failedFiles: string[] = []; + + // OCR typically processes one file at a time + for (let i = 0; i < validFiles.length; i++) { + const file = validFiles[i]; + setStatus(`Processing OCR for ${file.name} (${i + 1}/${validFiles.length})`); + + try { + const { formData, endpoint } = buildFormData(parameters, file); + const response = await axios.post(endpoint, formData, { + responseType: "blob", + timeout: 300000 // 5 minute timeout for OCR + }); + + // Check for HTTP errors + if (response.status >= 400) { + // Try to read error response as text + const errorText = await response.data.text(); + throw new Error(`OCR service HTTP error ${response.status}: ${errorText.substring(0, 300)}`); + } + + // Validate response + if (!response.data || response.data.size === 0) { + throw new Error('Empty response from OCR service'); + } + + const contentType = response.headers['content-type'] || 'application/pdf'; + + // Check if response is actually a PDF by examining the first few bytes + const arrayBuffer = await response.data.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + const header = new TextDecoder().decode(uint8Array.slice(0, 4)); + + // Check if it's a ZIP file (OCR service returns ZIP when sidecar is enabled or for multi-file results) + if (header.startsWith('PK')) { + try { + // Extract ZIP file contents + const zipFiles = await extractZipFile(response.data); + + // Add extracted files to processed files + processedFiles.push(...zipFiles); + } catch (extractError) { + // Fallback to treating as single ZIP file + const blob = new Blob([response.data], { type: 'application/zip' }); + const processedFile = new File([blob], `ocr_${file.name}.zip`, { type: 'application/zip' }); + processedFiles.push(processedFile); + } + continue; // Skip the PDF validation for ZIP files + } + + if (!header.startsWith('%PDF')) { + // Check if it's an error response + const text = new TextDecoder().decode(uint8Array.slice(0, 500)); + + if (text.includes('error') || text.includes('Error') || text.includes('exception') || text.includes('html')) { + // Check for specific OCR tool unavailable error + if (text.includes('OCR tools') && text.includes('not installed')) { + throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.'); + } + throw new Error(`OCR service error: ${text.substring(0, 300)}`); + } + + // Check if it's an HTML error page + if (text.includes(']*>([^<]+)<\/title>/i) || + text.match(/]*>([^<]+)<\/h1>/i) || + text.match(/]*>([^<]+)<\/body>/i); + const errorMessage = errorMatch ? errorMatch[1].trim() : 'Unknown error'; + throw new Error(`OCR service error: ${errorMessage}`); + } + + throw new Error(`Response is not a valid PDF file. Header: "${header}"`); + } + + const blob = new Blob([response.data], { type: contentType }); + const processedFile = new File([blob], `ocr_${file.name}`, { type: contentType }); + + processedFiles.push(processedFile); + } catch (fileError) { + const errorMessage = fileError instanceof Error ? fileError.message : 'Unknown error'; + failedFiles.push(`${file.name} (${errorMessage})`); + } + } + + if (failedFiles.length > 0 && processedFiles.length === 0) { + throw new Error(`Failed to process OCR for all files: ${failedFiles.join(', ')}`); + } + + if (failedFiles.length > 0) { + setStatus(`Processed ${processedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`); + } else { + const hasPdfFiles = processedFiles.some(file => file.name.endsWith('.pdf')); + const hasTxtFiles = processedFiles.some(file => file.name.endsWith('.txt')); + let statusMessage = `OCR completed successfully for ${processedFiles.length} file(s)`; + + if (hasPdfFiles && hasTxtFiles) { + statusMessage += ' (Extracted PDF and text files)'; + } else if (hasPdfFiles) { + statusMessage += ' (Extracted PDF files)'; + } else if (hasTxtFiles) { + statusMessage += ' (Extracted text files)'; + } + + setStatus(statusMessage); + } + + setFiles(processedFiles); + setIsGeneratingThumbnails(true); + + await addFiles(processedFiles); + + // Cleanup old blob URLs + cleanupBlobUrls(); + + // Create download URL - for multiple files, we'll create a new ZIP + if (processedFiles.length === 1) { + const url = window.URL.createObjectURL(processedFiles[0]); + setDownloadUrl(url); + setBlobUrls([url]); + setDownloadFilename(processedFiles[0].name); + } else { + // For multiple files, create a new ZIP containing all extracted files + try { + const JSZip = await import('jszip'); + const zip = new JSZip.default(); + + for (const file of processedFiles) { + zip.file(file.name, file); + } + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const url = window.URL.createObjectURL(zipBlob); + setDownloadUrl(url); + setBlobUrls([url]); + setDownloadFilename(`ocr_extracted_files.zip`); + } catch (zipError) { + // Fallback to first file + const url = window.URL.createObjectURL(processedFiles[0]); + setDownloadUrl(url); + setBlobUrls([url]); + setDownloadFilename(processedFiles[0].name); + } + } + + markOperationApplied(fileId, operationId); + setIsGeneratingThumbnails(false); + } catch (error) { + console.error('OCR operation error:', error); + const errorMessage = error instanceof Error ? error.message : 'OCR operation failed'; + setErrorMessage(errorMessage); + setStatus(''); + markOperationFailed(fileId, operationId, errorMessage); + } finally { + setIsLoading(false); + } + }, [buildFormData, createOperation, recordOperation, addFiles, cleanupBlobUrls, markOperationApplied, markOperationFailed, t]); + + const resetResults = useCallback(() => { + setFiles([]); + setThumbnails([]); + setDownloadUrl(null); + setDownloadFilename(''); + setStatus(''); + setErrorMessage(null); + setIsLoading(false); + setIsGeneratingThumbnails(false); + cleanupBlobUrls(); + }, [cleanupBlobUrls]); + + const clearError = useCallback(() => { + setErrorMessage(null); + }, []); + + return { + files, + thumbnails, + downloadUrl, + downloadFilename, + isLoading, + isGeneratingThumbnails, + status, + errorMessage, + executeOperation, + resetResults, + clearError, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/ocr/useOCRParameters.ts b/frontend/src/hooks/tools/ocr/useOCRParameters.ts new file mode 100644 index 000000000..23702cdce --- /dev/null +++ b/frontend/src/hooks/tools/ocr/useOCRParameters.ts @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { OCRParameters } from '../../../components/tools/ocr/OCRSettings'; + +export interface OCRParametersHook { + parameters: OCRParameters; + updateParameter: (key: keyof OCRParameters, value: any) => void; + resetParameters: () => void; + validateParameters: () => boolean; +} + +const defaultParameters: OCRParameters = { + languages: [], + ocrType: 'skip-text', + ocrRenderType: 'hocr', + additionalOptions: [], +}; + +export const useOCRParameters = (): OCRParametersHook => { + const [parameters, setParameters] = useState(defaultParameters); + + const updateParameter = (key: keyof OCRParameters, value: any) => { + setParameters(prev => ({ + ...prev, + [key]: value + })); + }; + + const resetParameters = () => { + setParameters(defaultParameters); + }; + + const validateParameters = () => { + // At minimum, we need at least one language selected + return parameters.languages.length > 0; + }; + + return { + parameters, + updateParameter, + resetParameters, + validateParameters, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useEndpointConfig.ts b/frontend/src/hooks/useEndpointConfig.ts index 13f13764b..5419f3506 100644 --- a/frontend/src/hooks/useEndpointConfig.ts +++ b/frontend/src/hooks/useEndpointConfig.ts @@ -19,7 +19,7 @@ export function useEndpointEnabled(endpoint: string): { setLoading(false); return; } - + try { setLoading(true); setError(null); @@ -35,7 +35,6 @@ export function useEndpointEnabled(endpoint: string): { } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; setError(errorMessage); - console.error(`Failed to check endpoint ${endpoint}:`, err); } finally { setLoading(false); } @@ -73,13 +72,14 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { setLoading(false); return; } - + try { setLoading(true); setError(null); // Use batch API for efficiency const endpointsParam = endpoints.join(','); + const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`); if (!response.ok) { @@ -105,6 +105,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { }; useEffect(() => { + const endpointsKey = endpoints.join(','); fetchAllEndpointStatuses(); }, [endpoints.join(',')]); // Re-run when endpoints array changes 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/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 ec2630267..879239f8c 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -1,13 +1,88 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { baseToolRegistry, toolEndpoints, type ToolRegistryEntry } from "../data/toolRegistry"; +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 { baseToolRegistry, toolEndpoints, type ToolRegistry, type ToolRegistryEntry } from "../data/toolRegistry"; +import { ToolDefinition, ToolRegistry, Tool } from "../types/tool"; + +// Add entry here with maxFiles, endpoints, and lazy component +const availableToolRegistry: Record = { + split: { + id: "split", + icon: , + component: React.lazy(() => import("../tools/Split")), + maxFiles: 1, + category: "manipulation", + description: "Split PDF files into smaller parts", + endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"] + }, + compress: { + id: "compress", + icon: , + component: React.lazy(() => import("../tools/Compress")), + maxFiles: -1, + category: "optimization", + description: "Reduce PDF file size", + endpoints: ["compress-pdf"] + }, + 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: , + component: React.lazy(() => import("../tools/SwaggerUI")), + maxFiles: 0, + category: "utility", + description: "Open API documentation", + endpoints: ["swagger-ui"] + }, + ocr: { + id: "ocr", + icon: + quick_reference_all + , + component: React.lazy(() => import("../tools/OCR")), + maxFiles: -1, + category: "utility", + description: "Extract text from images using OCR", + endpoints: ["ocr-pdf"] + }, + +}; interface ToolManagementResult { selectedToolKey: string | null; selectedTool: ToolRegistryEntry | null; toolSelectedFileIds: string[]; - toolRegistry: ToolRegistry; + toolRegistry: Record; selectTool: (toolKey: string) => void; clearToolSelection: () => void; setToolSelectedFileIds: (fileIds: string[]) => void; @@ -30,8 +105,8 @@ export const useToolManagement = (): ToolManagementResult => { return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true); }, [endpointsLoading, endpointStatus]); - const toolRegistry: ToolRegistry = useMemo(() => { - const availableToolRegistry: ToolRegistry = {}; + const toolRegistry: Record = useMemo(() => { + const availableToolRegistry: Record = {}; Object.keys(baseToolRegistry).forEach(toolKey => { if (isToolAvailable(toolKey)) { const baseTool = baseToolRegistry[toolKey as keyof typeof baseToolRegistry]; 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 3a60071ef..e5c9a151e 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,30 +181,18 @@ function HomePageContent() { }} > {!activeFiles[0] ? ( - - { - addToActiveFiles(file); - }} - onFilesSelect={(files) => { - files.forEach(addToActiveFiles); - }} - accept={["application/pdf"]} - loading={false} - showRecentFiles={true} - maxRecentFiles={8} - /> - + ) : currentView === "fileEditor" ? ( { handleViewChange("pageEditor"); @@ -236,6 +222,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'); } @@ -271,25 +262,15 @@ function HomePageContent() { selectedToolKey={selectedToolKey} /> ) : ( - - { - addToActiveFiles(file); - }} - onFilesSelect={(files) => { - files.forEach(addToActiveFiles); - }} - accept={["application/pdf"]} - loading={false} - showRecentFiles={true} - maxRecentFiles={8} - /> - + )} + + {/* Global Modals */} + ); } 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..90d203b55 --- /dev/null +++ b/frontend/src/tests/convert/ConvertE2E.spec.ts @@ -0,0 +1,440 @@ +/** + * 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'; +}; + +/** + * 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 + */ +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 QuickAccessBar to appear + await page.waitForSelector('[data-testid="files-button"]', { 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 uploadFileViaModal(page, testFile); + + 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 uploadFileViaModal(page, testFile); + + 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 uploadFileViaModal(page, testFile); + + 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 uploadFileViaModal(page, testFile); + + 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 uploadFileViaModal(page, testFile); + + 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 uploadFileViaModal(page, testFile); + + 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 uploadFileViaModal(page, testFile); + + 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 uploadFileViaModal(page, testFile); + + 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 uploadFileViaModal(page, testFile); + + 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 uploadFileViaModal(page, TEST_FILES.pdf); + + // 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 000000000..45fc50c23 Binary files /dev/null and b/frontend/src/tests/test-fixtures/sample.docx differ 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

+
    +
  • 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.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 000000000..a2dc48c27 Binary files /dev/null and b/frontend/src/tests/test-fixtures/sample.jpg differ 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 000000000..a7fb3ba0b Binary files /dev/null and b/frontend/src/tests/test-fixtures/sample.pdf differ diff --git a/frontend/src/tests/test-fixtures/sample.png b/frontend/src/tests/test-fixtures/sample.png new file mode 100644 index 000000000..b6993935a Binary files /dev/null and b/frontend/src/tests/test-fixtures/sample.png differ 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/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index 91670d6cf..cc263f126 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -167,6 +167,39 @@ export const mantineTheme = createTheme({ }, }, + MultiSelect: { + styles: { + input: { + backgroundColor: 'var(--bg-surface)', + borderColor: 'var(--border-default)', + color: 'var(--text-primary)', + '&:focus': { + borderColor: 'var(--color-primary-500)', + boxShadow: '0 0 0 1px var(--color-primary-500)', + }, + }, + label: { + color: 'var(--text-secondary)', + fontWeight: 'var(--font-weight-medium)', + }, + dropdown: { + backgroundColor: 'var(--bg-surface)', + borderColor: 'var(--border-subtle)', + boxShadow: 'var(--shadow-lg)', + }, + option: { + color: 'var(--text-primary)', + '&[data-hovered]': { + backgroundColor: 'var(--hover-bg)', + }, + '&[data-selected]': { + backgroundColor: 'var(--color-primary-100)', + color: 'var(--color-primary-900)', + }, + }, + }, + }, + Checkbox: { styles: { input: { 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/tools/OCR.tsx b/frontend/src/tools/OCR.tsx new file mode 100644 index 000000000..3e6fb0bdf --- /dev/null +++ b/frontend/src/tools/OCR.tsx @@ -0,0 +1,215 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Button, Stack, Text, Box } 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 OCRSettings from "../components/tools/ocr/OCRSettings"; +import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings"; + +import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters"; +import { useOCROperation } from "../hooks/tools/ocr/useOCROperation"; +import { BaseToolProps } from "../types/tool"; + +const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { setCurrentMode } = useFileContext(); + const { selectedFiles } = useToolFileSelection(); + + const ocrParams = useOCRParameters(); + const ocrOperation = useOCROperation(); + + // Step expansion state management + const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files'); + + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("ocr-pdf"); + + const hasFiles = selectedFiles.length > 0; + const hasResults = ocrOperation.files.length > 0 || ocrOperation.downloadUrl !== null; + const hasValidSettings = ocrParams.validateParameters(); + + useEffect(() => { + ocrOperation.resetResults(); + onPreviewFile?.(null); + }, [ocrParams.parameters, selectedFiles]); + + useEffect(() => { + if (selectedFiles.length > 0 && expandedStep === 'files') { + setExpandedStep('settings'); + } + }, [selectedFiles.length, expandedStep]); + + // Collapse all steps when results appear + useEffect(() => { + if (hasResults) { + setExpandedStep(null); + } + }, [hasResults]); + + const handleOCR = async () => { + try { + await ocrOperation.executeOperation( + ocrParams.parameters, + selectedFiles + ); + if (ocrOperation.files && onComplete) { + onComplete(ocrOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : 'OCR operation failed'); + } + } + }; + + const handleThumbnailClick = (file: File) => { + onPreviewFile?.(file); + sessionStorage.setItem('previousMode', 'ocr'); + setCurrentMode('viewer'); + }; + + + // Step visibility and collapse logic + const filesVisible = true; + const settingsVisible = true; + const resultsVisible = hasResults; + + const filesCollapsed = expandedStep !== 'files'; + const settingsCollapsed = expandedStep !== 'settings'; + + const previewResults = useMemo(() => + ocrOperation.files?.map((file: File, index: number) => ({ + file, + thumbnail: ocrOperation.thumbnails[index] + })) || [], + [ocrOperation.files, ocrOperation.thumbnails] + ); + + return ( + + + {/* Files Step */} + + + + + {/* Settings Step */} + { + if (!hasFiles) return; // Only allow if files are selected + setExpandedStep(expandedStep === 'settings' ? null : 'settings'); + }} + completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined} + > + + + + + + + {/* Advanced Step */} + { + if (!hasFiles) return; // Only allow if files are selected + setExpandedStep(expandedStep === 'advanced' ? null : 'advanced'); + }} + completedMessage={hasFiles && hasResults && expandedStep !== 'advanced' ? "OCR processing completed" : undefined} + > + + + + {/* Process Button - Available after all configuration */} + {hasValidSettings && !hasResults && ( + + + + )} + + {/* Results Step */} + + + {ocrOperation.status && ( + {ocrOperation.status} + )} + + + + {ocrOperation.downloadUrl && ( + + )} + + + + + + + ); +} + +export default OCR; \ No newline at end of file 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 diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index d28490277..d9c049ae7 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -5,15 +5,13 @@ import { ProcessedFile } from './processing'; import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; -export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress'; +export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr'; -// Legacy types for backward compatibility during transition -export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor'; -export type ToolType = 'merge' | 'split' | 'compress' | null; +export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr'; export interface FileOperation { id: string; - type: 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload'; + type: OperationType; timestamp: number; fileIds: string[]; status: 'pending' | 'applied' | 'failed'; @@ -56,9 +54,6 @@ export interface FileContextState { // Current navigation state currentMode: ModeType; - // Legacy fields for backward compatibility - currentView: ViewType; - currentTool: ToolType; // Edit history and state fileEditHistory: Map; @@ -97,10 +92,6 @@ export interface FileContextActions { // Navigation setCurrentMode: (mode: ModeType) => void; - // Legacy navigation functions for backward compatibility - setCurrentView: (view: ViewType) => void; - setCurrentTool: (tool: ToolType) => void; - // Selection management setSelectedFiles: (fileIds: string[]) => void; setSelectedPages: (pageNumbers: number[]) => void; @@ -168,9 +159,6 @@ export interface WithFileContext { // URL parameter types for deep linking export interface FileContextUrlParams { mode?: ModeType; - // Legacy parameters for backward compatibility - view?: ViewType; - tool?: ToolType; fileIds?: string[]; pageIds?: string[]; zoom?: number; 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/languageMapping.ts b/frontend/src/utils/languageMapping.ts new file mode 100644 index 000000000..687c63258 --- /dev/null +++ b/frontend/src/utils/languageMapping.ts @@ -0,0 +1,1020 @@ +// Unified Language System - Tri-directional mapping between browser languages, OCR codes, and display names +// Replaces both languageMapping.ts and tempOcrLanguages.ts + +interface LanguageDefinition { + ocrCode: string; + displayName: string; + browserCodes: string[]; +} + +// Comprehensive language definitions with all mappings +const languageDefinitions: LanguageDefinition[] = [ + // English + { + ocrCode: 'eng', + displayName: 'English', + browserCodes: ['en', 'en-US', 'en-GB', 'en-AU', 'en-CA', 'en-IE', 'en-NZ', 'en-ZA'] + }, + + // Spanish + { + ocrCode: 'spa', + displayName: 'Spanish', + browserCodes: ['es', 'es-ES', 'es-MX', 'es-AR', 'es-CO', 'es-CL', 'es-PE', 'es-VE'] + }, + + // French + { + ocrCode: 'fra', + displayName: 'French', + browserCodes: ['fr', 'fr-FR', 'fr-CA', 'fr-BE', 'fr-CH'] + }, + + // German + { + ocrCode: 'deu', + displayName: 'German', + browserCodes: ['de', 'de-DE', 'de-AT', 'de-CH'] + }, + + // Portuguese + { + ocrCode: 'por', + displayName: 'Portuguese', + browserCodes: ['pt', 'pt-PT', 'pt-BR'] + }, + + // Italian + { + ocrCode: 'ita', + displayName: 'Italian', + browserCodes: ['it', 'it-IT', 'it-CH'] + }, + + // Chinese Simplified + { + ocrCode: 'chi_sim', + displayName: 'Chinese (Simplified)', + browserCodes: ['zh', 'zh-CN', 'zh-Hans'] + }, + + // Chinese Traditional + { + ocrCode: 'chi_tra', + displayName: 'Chinese (Traditional)', + browserCodes: ['zh-TW', 'zh-HK', 'zh-Hant'] + }, + + // Tibetan + { + ocrCode: 'bod', + displayName: 'Tibetan', + browserCodes: ['bo', 'zh-BO'] + }, + + // Japanese + { + ocrCode: 'jpn', + displayName: 'Japanese', + browserCodes: ['ja', 'ja-JP'] + }, + + // Korean + { + ocrCode: 'kor', + displayName: 'Korean', + browserCodes: ['ko', 'ko-KR'] + }, + + // Russian + { + ocrCode: 'rus', + displayName: 'Russian', + browserCodes: ['ru', 'ru-RU'] + }, + + // Arabic + { + ocrCode: 'ara', + displayName: 'Arabic', + browserCodes: ['ar', 'ar-SA', 'ar-EG', 'ar-AE', 'ar-MA'] + }, + + // Dutch + { + ocrCode: 'nld', + displayName: 'Dutch; Flemish', + browserCodes: ['nl', 'nl-NL', 'nl-BE'] + }, + + // Polish + { + ocrCode: 'pol', + displayName: 'Polish', + browserCodes: ['pl', 'pl-PL'] + }, + + // Czech + { + ocrCode: 'ces', + displayName: 'Czech', + browserCodes: ['cs', 'cs-CZ'] + }, + + // Slovak + { + ocrCode: 'slk', + displayName: 'Slovak', + browserCodes: ['sk', 'sk-SK'] + }, + + // Hungarian + { + ocrCode: 'hun', + displayName: 'Hungarian', + browserCodes: ['hu', 'hu-HU'] + }, + + // Romanian + { + ocrCode: 'ron', + displayName: 'Romanian, Moldavian, Moldovan', + browserCodes: ['ro', 'ro-RO'] + }, + + // Bulgarian + { + ocrCode: 'bul', + displayName: 'Bulgarian', + browserCodes: ['bg', 'bg-BG'] + }, + + // Croatian + { + ocrCode: 'hrv', + displayName: 'Croatian', + browserCodes: ['hr', 'hr-HR'] + }, + + // Serbian + { + ocrCode: 'srp', + displayName: 'Serbian', + browserCodes: ['sr', 'sr-RS'] + }, + + // Serbian Latin + { + ocrCode: 'srp_latn', + displayName: 'Serbian (Latin)', + browserCodes: ['sr-Latn'] + }, + + // Slovenian + { + ocrCode: 'slv', + displayName: 'Slovenian', + browserCodes: ['sl', 'sl-SI'] + }, + + // Estonian + { + ocrCode: 'est', + displayName: 'Estonian', + browserCodes: ['et', 'et-EE'] + }, + + // Latvian + { + ocrCode: 'lav', + displayName: 'Latvian', + browserCodes: ['lv', 'lv-LV'] + }, + + // Lithuanian + { + ocrCode: 'lit', + displayName: 'Lithuanian', + browserCodes: ['lt', 'lt-LT'] + }, + + // Finnish + { + ocrCode: 'fin', + displayName: 'Finnish', + browserCodes: ['fi', 'fi-FI'] + }, + + // Swedish + { + ocrCode: 'swe', + displayName: 'Swedish', + browserCodes: ['sv', 'sv-SE'] + }, + + // Norwegian + { + ocrCode: 'nor', + displayName: 'Norwegian', + browserCodes: ['no', 'nb', 'nn', 'no-NO', 'nb-NO', 'nn-NO'] + }, + + // Danish + { + ocrCode: 'dan', + displayName: 'Danish', + browserCodes: ['da', 'da-DK'] + }, + + // Icelandic + { + ocrCode: 'isl', + displayName: 'Icelandic', + browserCodes: ['is', 'is-IS'] + }, + + // Greek + { + ocrCode: 'ell', + displayName: 'Greek', + browserCodes: ['el', 'el-GR'] + }, + + // Turkish + { + ocrCode: 'tur', + displayName: 'Turkish', + browserCodes: ['tr', 'tr-TR'] + }, + + // Hebrew + { + ocrCode: 'heb', + displayName: 'Hebrew', + browserCodes: ['he', 'he-IL'] + }, + + // Hindi + { + ocrCode: 'hin', + displayName: 'Hindi', + browserCodes: ['hi', 'hi-IN'] + }, + + // Thai + { + ocrCode: 'tha', + displayName: 'Thai', + browserCodes: ['th', 'th-TH'] + }, + + // Vietnamese + { + ocrCode: 'vie', + displayName: 'Vietnamese', + browserCodes: ['vi', 'vi-VN'] + }, + + // Indonesian + { + ocrCode: 'ind', + displayName: 'Indonesian', + browserCodes: ['id', 'id-ID'] + }, + + // Malay + { + ocrCode: 'msa', + displayName: 'Malay', + browserCodes: ['ms', 'ms-MY'] + }, + + // Filipino + { + ocrCode: 'fil', + displayName: 'Filipino', + browserCodes: ['fil'] + }, + + // Tagalog + { + ocrCode: 'tgl', + displayName: 'Tagalog', + browserCodes: ['tl'] + }, + + // Ukrainian + { + ocrCode: 'ukr', + displayName: 'Ukrainian', + browserCodes: ['uk', 'uk-UA'] + }, + + // Belarusian + { + ocrCode: 'bel', + displayName: 'Belarusian', + browserCodes: ['be', 'be-BY'] + }, + + // Kazakh + { + ocrCode: 'kaz', + displayName: 'Kazakh', + browserCodes: ['kk', 'kk-KZ'] + }, + + // Uzbek + { + ocrCode: 'uzb', + displayName: 'Uzbek', + browserCodes: ['uz', 'uz-UZ'] + }, + + // Georgian + { + ocrCode: 'kat', + displayName: 'Georgian', + browserCodes: ['ka', 'ka-GE'] + }, + + // Armenian + { + ocrCode: 'hye', + displayName: 'Armenian', + browserCodes: ['hy', 'hy-AM'] + }, + + // Azerbaijani + { + ocrCode: 'aze', + displayName: 'Azerbaijani', + browserCodes: ['az', 'az-AZ'] + }, + + // Persian/Farsi + { + ocrCode: 'fas', + displayName: 'Persian', + browserCodes: ['fa', 'fa-IR'] + }, + + // Urdu + { + ocrCode: 'urd', + displayName: 'Urdu', + browserCodes: ['ur', 'ur-PK'] + }, + + // Bengali + { + ocrCode: 'ben', + displayName: 'Bengali', + browserCodes: ['bn', 'bn-BD', 'bn-IN'] + }, + + // Tamil + { + ocrCode: 'tam', + displayName: 'Tamil', + browserCodes: ['ta', 'ta-IN', 'ta-LK'] + }, + + // Telugu + { + ocrCode: 'tel', + displayName: 'Telugu', + browserCodes: ['te', 'te-IN'] + }, + + // Kannada + { + ocrCode: 'kan', + displayName: 'Kannada', + browserCodes: ['kn', 'kn-IN'] + }, + + // Malayalam + { + ocrCode: 'mal', + displayName: 'Malayalam', + browserCodes: ['ml', 'ml-IN'] + }, + + // Gujarati + { + ocrCode: 'guj', + displayName: 'Gujarati', + browserCodes: ['gu', 'gu-IN'] + }, + + // Marathi + { + ocrCode: 'mar', + displayName: 'Marathi', + browserCodes: ['mr', 'mr-IN'] + }, + + // Punjabi + { + ocrCode: 'pan', + displayName: 'Panjabi, Punjabi', + browserCodes: ['pa', 'pa-IN'] + }, + + // Nepali + { + ocrCode: 'nep', + displayName: 'Nepali', + browserCodes: ['ne', 'ne-NP'] + }, + + // Sinhala + { + ocrCode: 'sin', + displayName: 'Sinhala, Sinhalese', + browserCodes: ['si', 'si-LK'] + }, + + // Burmese + { + ocrCode: 'mya', + displayName: 'Burmese', + browserCodes: ['my', 'my-MM'] + }, + + // Khmer + { + ocrCode: 'khm', + displayName: 'Central Khmer', + browserCodes: ['km', 'km-KH'] + }, + + // Lao + { + ocrCode: 'lao', + displayName: 'Lao', + browserCodes: ['lo', 'lo-LA'] + }, + + // Mongolian + { + ocrCode: 'mon', + displayName: 'Mongolian', + browserCodes: ['mn', 'mn-MN'] + }, + + // Welsh + { + ocrCode: 'cym', + displayName: 'Welsh', + browserCodes: ['cy', 'cy-GB'] + }, + + // Irish + { + ocrCode: 'gle', + displayName: 'Irish', + browserCodes: ['ga', 'ga-IE'] + }, + + // Scottish Gaelic + { + ocrCode: 'gla', + displayName: 'Scottish Gaelic', + browserCodes: ['gd', 'gd-GB'] + }, + + // Basque + { + ocrCode: 'eus', + displayName: 'Basque', + browserCodes: ['eu', 'eu-ES'] + }, + + // Catalan + { + ocrCode: 'cat', + displayName: 'Catalan', + browserCodes: ['ca', 'ca-ES'] + }, + + // Galician + { + ocrCode: 'glg', + displayName: 'Galician', + browserCodes: ['gl', 'gl-ES'] + }, + + // Macedonian + { + ocrCode: 'mkd', + displayName: 'Macedonian', + browserCodes: ['mk', 'mk-MK'] + }, + + // Albanian + { + ocrCode: 'sqi', + displayName: 'Albanian', + browserCodes: ['sq', 'sq-AL'] + }, + + // Maltese + { + ocrCode: 'mlt', + displayName: 'Maltese', + browserCodes: ['mt', 'mt-MT'] + }, + + // Afrikaans + { + ocrCode: 'afr', + displayName: 'Afrikaans', + browserCodes: ['af', 'af-ZA'] + }, + + // Swahili + { + ocrCode: 'swa', + displayName: 'Swahili', + browserCodes: ['sw', 'sw-KE', 'sw-TZ'] + }, + + // Amharic + { + ocrCode: 'amh', + displayName: 'Amharic', + browserCodes: ['am'] + }, + + // Assamese + { + ocrCode: 'asm', + displayName: 'Assamese', + browserCodes: ['as'] + }, + + // Azerbaijani (Cyrillic) + { + ocrCode: 'aze_cyrl', + displayName: 'Azerbaijani (Cyrillic)', + browserCodes: [] + }, + + // Bosnian + { + ocrCode: 'bos', + displayName: 'Bosnian', + browserCodes: ['bs'] + }, + + // Breton + { + ocrCode: 'bre', + displayName: 'Breton', + browserCodes: ['br'] + }, + + // Bambara + { + ocrCode: 'bam', + displayName: 'Bambara', + browserCodes: ['bm'] + }, + + // Bashkir + { + ocrCode: 'bak', + displayName: 'Bashkir', + browserCodes: ['ba'] + }, + + // Cornish + { + ocrCode: 'cor', + displayName: 'Cornish', + browserCodes: ['kw'] + }, + + // Corsican + { + ocrCode: 'cos', + displayName: 'Corsican', + browserCodes: ['co'] + }, + + // Ewe + { + ocrCode: 'ewe', + displayName: 'Ewe', + browserCodes: ['ee'] + }, + + // Faroese + { + ocrCode: 'fao', + displayName: 'Faroese', + browserCodes: ['fo'] + }, + + // Fijian + { + ocrCode: 'fij', + displayName: 'Fijian', + browserCodes: ['fj'] + }, + + // Haitian Creole + { + ocrCode: 'hat', + displayName: 'Haitian, Haitian Creole', + browserCodes: ['ht'] + }, + + // Javanese + { + ocrCode: 'jav', + displayName: 'Javanese', + browserCodes: ['jv'] + }, + + // Kirghiz + { + ocrCode: 'kir', + displayName: 'Kirghiz, Kyrgyz', + browserCodes: ['ky'] + }, + + // Quechua + { + ocrCode: 'que', + displayName: 'Quechua', + browserCodes: ['qu'] + }, + + // Sindhi + { + ocrCode: 'snd', + displayName: 'Sindhi', + browserCodes: ['sd'] + }, + + // Yiddish + { + ocrCode: 'yid', + displayName: 'Yiddish', + browserCodes: ['yi'] + }, + + // Yoruba + { + ocrCode: 'yor', + displayName: 'Yoruba', + browserCodes: ['yo'] + }, + + // Additional OCR languages without browser mappings or with very specific/rare codes + { + ocrCode: 'ceb', + displayName: 'Cebuano', + browserCodes: [] + }, + { + ocrCode: 'chi_sim_vert', + displayName: 'Chinese (Simplified, Vertical)', + browserCodes: [] + }, + { + ocrCode: 'chi_tra_vert', + displayName: 'Chinese (Traditional, Vertical)', + browserCodes: [] + }, + { + ocrCode: 'chr', + displayName: 'Cherokee', + browserCodes: [] + }, + { + ocrCode: 'dan_frak', + displayName: 'Danish (Fraktur)', + browserCodes: [] + }, + { + ocrCode: 'deu_frak', + displayName: 'German (Fraktur)', + browserCodes: [] + }, + { + ocrCode: 'div', + displayName: 'Divehi', + browserCodes: ['dv'] + }, + { + ocrCode: 'dzo', + displayName: 'Dzongkha', + browserCodes: ['dz'] + }, + { + ocrCode: 'enm', + displayName: 'English, Middle (1100-1500)', + browserCodes: [] + }, + { + ocrCode: 'epo', + displayName: 'Esperanto', + browserCodes: ['eo'] + }, + { + ocrCode: 'equ', + displayName: 'Math / equation detection module', + browserCodes: [] + }, + { + ocrCode: 'frk', + displayName: 'Frankish', + browserCodes: [] + }, + { + ocrCode: 'frm', + displayName: 'French, Middle (ca.1400-1600)', + browserCodes: [] + }, + { + ocrCode: 'fry', + displayName: 'Western Frisian', + browserCodes: ['fy'] + }, + { + ocrCode: 'grc', + displayName: 'Ancient Greek', + browserCodes: [] + }, + { + ocrCode: 'iku', + displayName: 'Inuktitut', + browserCodes: ['iu'] + }, + { + ocrCode: 'ita_old', + displayName: 'Italian (Old)', + browserCodes: [] + }, + { + ocrCode: 'jpn_vert', + displayName: 'Japanese (Vertical)', + browserCodes: [] + }, + { + ocrCode: 'kat_old', + displayName: 'Georgian (Old)', + browserCodes: [] + }, + { + ocrCode: 'kmr', + displayName: 'Northern Kurdish', + browserCodes: ['ku'] + }, + { + ocrCode: 'kor_vert', + displayName: 'Korean (Vertical)', + browserCodes: [] + }, + { + ocrCode: 'lat', + displayName: 'Latin', + browserCodes: ['la'] + }, + { + ocrCode: 'ltz', + displayName: 'Luxembourgish', + browserCodes: ['lb'] + }, + { + ocrCode: 'mri', + displayName: 'Maori', + browserCodes: ['mi'] + }, + { + ocrCode: 'oci', + displayName: 'Occitan (post 1500)', + browserCodes: ['oc'] + }, + { + ocrCode: 'ori', + displayName: 'Oriya', + browserCodes: ['or'] + }, + { + ocrCode: 'osd', + displayName: 'Orientation and script detection module', + browserCodes: [] + }, + { + ocrCode: 'pus', + displayName: 'Pushto, Pashto', + browserCodes: ['ps'] + }, + { + ocrCode: 'san', + displayName: 'Sanskrit', + browserCodes: ['sa'] + }, + { + ocrCode: 'slk_frak', + displayName: 'Slovak (Fraktur)', + browserCodes: [] + }, + { + ocrCode: 'spa_old', + displayName: 'Spanish (Old)', + browserCodes: [] + }, + { + ocrCode: 'sun', + displayName: 'Sundanese', + browserCodes: ['su'] + }, + { + ocrCode: 'syr', + displayName: 'Syriac', + browserCodes: [] + }, + { + ocrCode: 'tat', + displayName: 'Tatar', + browserCodes: ['tt'] + }, + { + ocrCode: 'tgk', + displayName: 'Tajik', + browserCodes: ['tg'] + }, + { + ocrCode: 'tir', + displayName: 'Tigrinya', + browserCodes: ['ti'] + }, + { + ocrCode: 'ton', + displayName: 'Tonga (Tonga Islands)', + browserCodes: ['to'] + }, + { + ocrCode: 'uig', + displayName: 'Uighur, Uyghur', + browserCodes: ['ug'] + }, + { + ocrCode: 'uzb_cyrl', + displayName: 'Uzbek (Cyrillic)', + browserCodes: [] + } +]; + +// Build lookup maps for efficient access +const browserToOcrMap = new Map(); +const ocrToDisplayMap = new Map(); +const displayToOcrMap = new Map(); +const ocrToBrowserMap = new Map(); + +// Populate lookup maps +languageDefinitions.forEach(lang => { + // OCR code to display name + ocrToDisplayMap.set(lang.ocrCode, lang.displayName); + + // Display name to OCR code + displayToOcrMap.set(lang.displayName.toLowerCase(), lang.ocrCode); + + // OCR code to browser codes + ocrToBrowserMap.set(lang.ocrCode, lang.browserCodes); + + // Browser codes to OCR code + lang.browserCodes.forEach(browserCode => { + browserToOcrMap.set(browserCode.toLowerCase(), lang.ocrCode); + }); +}); + +/** + * Maps a browser language code to an OCR language code + * Handles exact matches and similar language fallbacks + * + * @param browserLanguage - The browser language code (e.g., 'en-GB', 'fr-FR') + * @returns OCR language code if found, null if no match + * + * @example + * mapBrowserLanguageToOcr('de-DE') // Returns 'deu' + * mapBrowserLanguageToOcr('en-GB') // Returns 'eng' + * mapBrowserLanguageToOcr('zh-CN') // Returns 'chi_sim' + */ +export function mapBrowserLanguageToOcr(browserLanguage: string): string | null { + if (!browserLanguage) return null; + + // Normalize the input + const normalizedInput = browserLanguage.toLowerCase().replace('_', '-'); + + // Try exact match first + const exactMatch = browserToOcrMap.get(normalizedInput); + if (exactMatch) return exactMatch; + + // Try with different casing variations + const variations = [ + browserLanguage.toLowerCase(), + browserLanguage.toUpperCase().toLowerCase(), + normalizedInput, + ]; + + for (const variant of variations) { + const match = browserToOcrMap.get(variant); + if (match) return match; + } + + // Try base language code (e.g., 'en' from 'en-GB') + const baseLanguage = normalizedInput.split('-')[0]; + const baseMatch = browserToOcrMap.get(baseLanguage); + if (baseMatch) return baseMatch; + + // No match found + return null; +} + +/** + * Gets the display name for an OCR language code + * + * @param ocrCode - The OCR language code (e.g., 'eng', 'deu') + * @returns Display name if found, the original code if not found + * + * @example + * getOcrDisplayName('deu') // Returns 'German' + * getOcrDisplayName('eng') // Returns 'English' + * getOcrDisplayName('chi_sim') // Returns 'Chinese (Simplified)' + */ +export function getOcrDisplayName(ocrCode: string): string { + return ocrToDisplayMap.get(ocrCode) || ocrCode; +} + +/** + * Gets the OCR code from a display name + * + * @param displayName - The display name (e.g., 'English', 'German') + * @returns OCR code if found, null if no match + * + * @example + * getOcrCodeFromDisplayName('German') // Returns 'deu' + * getOcrCodeFromDisplayName('English') // Returns 'eng' + * getOcrCodeFromDisplayName('chinese (simplified)') // Returns 'chi_sim' (case insensitive) + */ +export function getOcrCodeFromDisplayName(displayName: string): string | null { + return displayToOcrMap.get(displayName.toLowerCase()) || null; +} + +/** + * Gets the browser language codes for an OCR language code + * + * @param ocrCode - The OCR language code (e.g., 'eng', 'deu') + * @returns Array of browser language codes + * + * @example + * getBrowserLanguagesForOcr('deu') // Returns ['de', 'de-DE', 'de-AT', 'de-CH'] + * getBrowserLanguagesForOcr('eng') // Returns ['en', 'en-US', 'en-GB', 'en-AU', ...] + * getBrowserLanguagesForOcr('nor') // Returns ['no', 'nb', 'nn', 'no-NO', 'nb-NO', 'nn-NO'] + */ +export function getBrowserLanguagesForOcr(ocrCode: string): string[] { + return ocrToBrowserMap.get(ocrCode) || []; +} + +/** + * Gets the OCR language code for the current browser language + * + * @param currentLanguage - Current i18n language + * @returns OCR language code array (empty if no match) + * + * @example + * getAutoOcrLanguage('de-DE') // Returns ['deu'] + * getAutoOcrLanguage('en-GB') // Returns ['eng'] + * getAutoOcrLanguage('unknown') // Returns [] + */ +export function getAutoOcrLanguage(currentLanguage: string): string[] { + const ocrLanguage = mapBrowserLanguageToOcr(currentLanguage); + return ocrLanguage ? [ocrLanguage] : []; +} + +/** + * Gets all available language definitions + * + * @returns Array of all language definitions + * + * @example + * const allLanguages = getAllLanguageDefinitions(); + * // Returns: [{ ocrCode: 'eng', displayName: 'English', browserCodes: ['en', 'en-US', ...] }, ...] + */ +export function getAllLanguageDefinitions(): LanguageDefinition[] { + return [...languageDefinitions]; +} + +/** + * Legacy compatibility - provides the same interface as tempOcrLanguages.ts + */ +export const tempOcrLanguages = { + lang: Object.fromEntries(ocrToDisplayMap) +} as const; \ 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