This commit is contained in:
EthanHealy01 2025-08-07 02:01:18 +01:00
commit fa022c1440
162 changed files with 15208 additions and 350 deletions

View File

@ -6,7 +6,10 @@
"Bash(./gradlew:*)", "Bash(./gradlew:*)",
"Bash(grep:*)", "Bash(grep:*)",
"Bash(cat:*)", "Bash(cat:*)",
"Bash(find:*)" "Bash(find:*)",
"Bash(npm test)",
"Bash(npm test:*)",
"Bash(ls:*)"
], ],
"deny": [] "deny": []
} }

View File

@ -48,6 +48,7 @@ jobs:
"DarioGii" "DarioGii"
"ConnorYoh" "ConnorYoh"
"EthanHealy01" "EthanHealy01"
"jbrunton96"
) )
# Check if author is in the authorized list # Check if author is in the authorized list
@ -317,6 +318,7 @@ jobs:
SYSTEM_MAXFILESIZE: "100" SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true" METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false" SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:${V2_PORT}"
restart: on-failure:5 restart: on-failure:5
stirling-pdf-v2-frontend: stirling-pdf-v2-frontend:
@ -385,10 +387,12 @@ jobs:
} }
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`; const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` + const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with the new frontend/backend split architecture has been deployed!\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` + `_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.`; `🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;

View File

@ -29,6 +29,7 @@ jobs:
github.event.comment.user.login == 'reecebrowne' || github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' || github.event.comment.user.login == 'EthanHealy01' ||
github.event.comment.user.login == 'jbrunton96' ||
github.event.comment.user.login == 'ConnorYoh' github.event.comment.user.login == 'ConnorYoh'
) )
outputs: outputs:

View File

@ -130,7 +130,7 @@ jobs:
- name: Build frontend - name: Build frontend
run: cd frontend && npm run build run: cd frontend && npm run build
- name: Run frontend tests - 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 - name: Upload frontend build artifacts
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:

View File

@ -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

View File

@ -126,19 +126,19 @@ jobs:
commentBody = `## ❌ Frontend License Check Failed 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 { } else {
commentBody = `## ✅ Frontend License Check Passed 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({ await github.rest.issues.createComment({

2
.gitignore vendored
View File

@ -27,7 +27,7 @@ clientWebUI/
!cucumber/exampleFiles/ !cucumber/exampleFiles/
!cucumber/exampleFiles/example_html.zip !cucumber/exampleFiles/example_html.zip
exampleYmlFiles/stirling/ exampleYmlFiles/stirling/
stirling/ /stirling/
/testing/file_snapshots /testing/file_snapshots
SwaggerDoc.json SwaggerDoc.json

View File

@ -39,6 +39,11 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
String queryString = request.getQueryString(); String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) { if (queryString != null && !queryString.isEmpty()) {
String requestURI = request.getRequestURI(); String requestURI = request.getRequestURI();
if (requestURI.contains("/api/")) {
return true;
}
Map<String, String> allowedParameters = new HashMap<>(); Map<String, String> allowedParameters = new HashMap<>();
// Keep only the allowed parameters // Keep only the allowed parameters

View File

@ -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.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -50,17 +51,26 @@ public class OpenApiConfig {
.url("https://www.stirlingpdf.com") .url("https://www.stirlingpdf.com")
.email("contact@stirlingpdf.com")) .email("contact@stirlingpdf.com"))
.description(DEFAULT_DESCRIPTION); .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()) { if (!applicationProperties.getSecurity().getEnableLogin()) {
return new OpenAPI().components(new Components()).info(info); return openAPI.components(new Components());
} else { } else {
SecurityScheme apiKeyScheme = SecurityScheme apiKeyScheme =
new SecurityScheme() new SecurityScheme()
.type(SecurityScheme.Type.APIKEY) .type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER) .in(SecurityScheme.In.HEADER)
.name("X-API-KEY"); .name("X-API-KEY");
return new OpenAPI() return openAPI
.components(new Components().addSecuritySchemes("apiKey", apiKeyScheme)) .components(new Components().addSecuritySchemes("apiKey", apiKeyScheme))
.info(info)
.addSecurityItem(new SecurityRequirement().addList("apiKey")); .addSecurityItem(new SecurityRequirement().addList("apiKey"));
} }
} }

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.*; import java.util.*;
@ -29,7 +31,7 @@ public class AnalysisController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/page-count", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/page-count", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Get PDF page count", summary = "Get PDF page count",
description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO") 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( @Operation(
summary = "Get basic PDF information", summary = "Get basic PDF information",
description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO") 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( @Operation(
summary = "Get PDF document properties", summary = "Get PDF document properties",
description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO") 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( @Operation(
summary = "Get page dimensions for all pages", summary = "Get page dimensions for all pages",
description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO") 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( @Operation(
summary = "Get form field information", summary = "Get form field information",
description = 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( @Operation(
summary = "Get annotation information", summary = "Get annotation information",
description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO") 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( @Operation(
summary = "Get font information", summary = "Get font information",
description = 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( @Operation(
summary = "Get security information", summary = "Get security information",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -33,7 +35,7 @@ public class CropController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/crop", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/crop", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Crops a PDF document", summary = "Crops a PDF document",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@ -44,7 +46,7 @@ public class EditTableOfContentsController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@PostMapping(value = "/extract-bookmarks", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/extract-bookmarks", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Extract PDF Bookmarks", summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.") description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ -152,7 +154,7 @@ public class EditTableOfContentsController {
return bookmark; return bookmark;
} }
@PostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Edit Table of Contents", summary = "Edit Table of Contents",
description = "Add or edit bookmarks/table of contents in a PDF document.") description = "Add or edit bookmarks/table of contents in a PDF document.")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; 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( @Operation(
summary = "Merge multiple PDF files into one", summary = "Merge multiple PDF files into one",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -36,7 +38,7 @@ public class MultiPageLayoutController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/multi-page-layout", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/multi-page-layout", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Merge multiple pages of a PDF document into a single page", summary = "Merge multiple pages of a PDF document into a single page",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -46,7 +48,7 @@ public class PdfImageRemovalController {
* content type and filename. * content type and filename.
* @throws IOException If an error occurs while processing the PDF file. * @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( @Operation(
summary = "Remove images from file to reduce the file size.", summary = "Remove images from file to reduce the file size.",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@ -39,7 +41,7 @@ public class PdfOverlayController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Overlay PDF files in various modes", summary = "Overlay PDF files in various modes",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -38,7 +40,7 @@ public class RearrangePagesPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-pages") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-pages")
@Operation( @Operation(
summary = "Remove pages from a PDF file", summary = "Remove pages from a PDF file",
description = 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( @Operation(
summary = "Rearrange pages in a PDF file", summary = "Rearrange pages in a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -31,7 +33,7 @@ public class RotationController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/rotate-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/rotate-pdf")
@Operation( @Operation(
summary = "Rotate a PDF file", summary = "Rotate a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
@ -38,7 +40,7 @@ public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/scale-pages", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/scale-pages", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Change the size of a PDF page/document", summary = "Change the size of a PDF page/document",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
@ -31,7 +33,7 @@ public class SettingsController {
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private final EndpointConfiguration endpointConfiguration; private final EndpointConfiguration endpointConfiguration;
@PostMapping("/update-enable-analytics") @AutoJobPostMapping("/update-enable-analytics")
@Hidden @Hidden
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException { public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
if (applicationProperties.getSystem().getEnableAnalytics() != null) { if (applicationProperties.getSystem().getEnableAnalytics() != null) {

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -41,7 +43,7 @@ public class SplitPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/split-pages") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/split-pages")
@Operation( @Operation(
summary = "Split a PDF file into separate documents", summary = "Split a PDF file into separate documents",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -117,7 +119,7 @@ public class SplitPdfByChaptersController {
return bookmarks; return bookmarks;
} }
@PostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Split PDFs by Chapters", summary = "Split PDFs by Chapters",
description = "Splits a PDF into chapters and returns a ZIP file.") description = "Splits a PDF into chapters and returns a ZIP file.")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -43,7 +45,7 @@ public class SplitPdfBySectionsController {
private final CustomPDFDocumentFactory pdfDocumentFactory; 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( @Operation(
summary = "Split PDF pages into smaller sections", summary = "Split PDF pages into smaller sections",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -39,7 +41,7 @@ public class SplitPdfBySizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory; 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( @Operation(
summary = "Auto split PDF pages into separate documents based on size or count", summary = "Auto split PDF pages into separate documents based on size or count",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.geom.AffineTransform; import java.awt.geom.AffineTransform;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -33,7 +35,7 @@ public class ToSinglePageController {
private final CustomPDFDocumentFactory pdfDocumentFactory; 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( @Operation(
summary = "Convert a multi-page PDF into a single long page PDF", summary = "Convert a multi-page PDF into a single long page PDF",
description = description =

View File

@ -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<HomeData> 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<LicensesData> 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<String, List<Dependency>> 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<PipelineData> getPipelineData() {
PipelineData data = new PipelineData();
List<String> pipelineConfigs = new ArrayList<>();
List<Map<String, String>> pipelineConfigsWithNames = new ArrayList<>();
if (new java.io.File(runtimePathConfig.getPipelineDefaultWebUiConfigs()).exists()) {
try (Stream<Path> paths =
Files.walk(Paths.get(runtimePathConfig.getPipelineDefaultWebUiConfigs()))) {
List<Path> 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<String, Object> jsonContent =
new ObjectMapper()
.readValue(config, new TypeReference<Map<String, Object>>() {});
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<String, String> 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<String, String> 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<SignData> getSignData() {
String username = "";
if (userService != null) {
username = userService.getCurrentUsername();
}
List<SignatureFile> signatures = signatureService.getAvailableSignatures(username);
List<FontResource> 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<OcrData> getOcrPdfData() {
List<String> languages = getAvailableTesseractLanguages();
OcrData data = new OcrData();
data.setLanguages(languages);
return ResponseEntity.ok(data);
}
private List<String> 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<FontResource> getFontNames() {
List<FontResource> 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<FontResource> 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<Dependency> dependencies;
}
@Data
public static class PipelineData {
private List<Map<String, String>> pipelineConfigsWithNames;
private List<String> pipelineConfigs;
}
@Data
public static class SignData {
private List<SignatureFile> signatures;
private List<FontResource> fonts;
}
@Data
public static class OcrData {
private List<String> 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 "";
}
}
}
}

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -38,7 +40,7 @@ public class ConvertEmlToPDF {
private final RuntimePathConfig runtimePathConfig; private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/eml/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/eml/pdf")
@Operation( @Operation(
summary = "Convert EML to PDF", summary = "Convert EML to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -36,7 +38,7 @@ public class ConvertHtmlToPDF {
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/html/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf")
@Operation( @Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF", summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
@ -51,7 +53,7 @@ public class ConvertImgPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf/img") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/img")
@Operation( @Operation(
summary = "Convert PDF to image(s)", summary = "Convert PDF to image(s)",
description = 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( @Operation(
summary = "Convert images to a PDF file", summary = "Convert images to a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -45,7 +47,7 @@ public class ConvertMarkdownToPdf {
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/markdown/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
@Operation( @Operation(
summary = "Convert a Markdown file to PDF", summary = "Convert a Markdown file to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -84,7 +86,7 @@ public class ConvertOfficeController {
return fileExtension.matches(extensionPattern); return fileExtension.matches(extensionPattern);
} }
@PostMapping(consumes = "multipart/form-data", value = "/file/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/file/pdf")
@Operation( @Operation(
summary = "Convert a file to a PDF using LibreOffice", summary = "Convert a file to a PDF using LibreOffice",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -18,7 +20,7 @@ import stirling.software.common.util.PDFToFile;
@RequestMapping("/api/v1/convert") @RequestMapping("/api/v1/convert")
public class ConvertPDFToHtml { public class ConvertPDFToHtml {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/html") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/html")
@Operation( @Operation(
summary = "Convert PDF to HTML", summary = "Convert PDF to HTML",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -34,7 +36,7 @@ public class ConvertPDFToOffice {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf/presentation") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/presentation")
@Operation( @Operation(
summary = "Convert PDF to Presentation format", summary = "Convert PDF to Presentation format",
description = description =
@ -49,7 +51,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import"); 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( @Operation(
summary = "Convert PDF to Text or RTF format", summary = "Convert PDF to Text or RTF format",
description = 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( @Operation(
summary = "Convert PDF to Word document", summary = "Convert PDF to Word document",
description = description =
@ -91,7 +93,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import"); 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( @Operation(
summary = "Convert PDF to XML", summary = "Convert PDF to XML",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.Color; import java.awt.Color;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
@ -78,7 +80,7 @@ import stirling.software.common.util.WebResponseUtils;
@Tag(name = "Convert", description = "Convert APIs") @Tag(name = "Convert", description = "Convert APIs")
public class ConvertPDFToPDFA { public class ConvertPDFToPDFA {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa")
@Operation( @Operation(
summary = "Convert a PDF to a PDF/A", summary = "Convert a PDF to a PDF/A",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -40,7 +42,7 @@ public class ConvertWebsiteToPDF {
private final RuntimePathConfig runtimePathConfig; private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
@PostMapping(consumes = "multipart/form-data", value = "/url/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/url/pdf")
@Operation( @Operation(
summary = "Convert a URL to a PDF", summary = "Convert a URL to a PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.StringWriter; import java.io.StringWriter;
@ -46,7 +48,7 @@ public class ExtractCSVController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/pdf/csv", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/pdf/csv", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Extracts a CSV document from a PDF", summary = "Extracts a CSV document from a PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.filters; package stirling.software.SPDF.controller.api.filters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -37,7 +39,7 @@ public class FilterController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-text") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-text")
@Operation( @Operation(
summary = "Checks if a PDF contains set text, returns true if does", summary = "Checks if a PDF contains set text, returns true if does",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -55,7 +57,7 @@ public class FilterController {
} }
// TODO // TODO
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-image") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-image")
@Operation( @Operation(
summary = "Checks if a PDF contains an image", summary = "Checks if a PDF contains an image",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -71,7 +73,7 @@ public class FilterController {
return null; return null;
} }
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-count") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-count")
@Operation( @Operation(
summary = "Checks if a PDF is greater, less or equal to a setPageCount", summary = "Checks if a PDF is greater, less or equal to a setPageCount",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -104,7 +106,7 @@ public class FilterController {
return null; return null;
} }
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-size") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-size")
@Operation( @Operation(
summary = "Checks if a PDF is of a certain size", summary = "Checks if a PDF is of a certain size",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -147,7 +149,7 @@ public class FilterController {
return null; return null;
} }
@PostMapping(consumes = "multipart/form-data", value = "/filter-file-size") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-file-size")
@Operation( @Operation(
summary = "Checks if a PDF is a set file size", summary = "Checks if a PDF is a set file size",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -180,7 +182,7 @@ public class FilterController {
return null; return null;
} }
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation")
@Operation( @Operation(
summary = "Checks if a PDF is of a certain rotation", summary = "Checks if a PDF is of a certain rotation",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@ -34,7 +36,7 @@ public class AttachmentController {
private final AttachmentServiceInterface pdfAttachmentService; private final AttachmentServiceInterface pdfAttachmentService;
@PostMapping(consumes = "multipart/form-data", value = "/add-attachments") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-attachments")
@Operation( @Operation(
summary = "Add attachments to PDF", summary = "Add attachments to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@ -38,7 +40,7 @@ public class AutoRenameController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/auto-rename") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/auto-rename")
@Operation( @Operation(
summary = "Extract header from PDF file", summary = "Extract header from PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte; import java.awt.image.DataBufferByte;
import java.awt.image.DataBufferInt; 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( @Operation(
summary = "Auto split PDF pages into separate documents", summary = "Auto split PDF pages into separate documents",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -69,7 +71,7 @@ public class BlankPageController {
return whitePixelPercentage >= whitePercent; return whitePixelPercentage >= whitePercent;
} }
@PostMapping(consumes = "multipart/form-data", value = "/remove-blanks") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-blanks")
@Operation( @Operation(
summary = "Remove blank pages from a PDF file", summary = "Remove blank pages from a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream; 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( @Operation(
summary = "Optimize PDF file", summary = "Optimize PDF file",
description = description =

View File

@ -110,14 +110,14 @@ public class ConfigController {
} }
@GetMapping("/endpoint-enabled") @GetMapping("/endpoint-enabled")
public ResponseEntity<Boolean> isEndpointEnabled(@RequestParam String endpoint) { public ResponseEntity<Boolean> isEndpointEnabled(@RequestParam(name = "endpoint") String endpoint) {
boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint); boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint);
return ResponseEntity.ok(enabled); return ResponseEntity.ok(enabled);
} }
@GetMapping("/endpoints-enabled") @GetMapping("/endpoints-enabled")
public ResponseEntity<Map<String, Boolean>> areEndpointsEnabled( public ResponseEntity<Map<String, Boolean>> areEndpointsEnabled(
@RequestParam String endpoints) { @RequestParam(name = "endpoints") String endpoints) {
Map<String, Boolean> result = new HashMap<>(); Map<String, Boolean> result = new HashMap<>();
String[] endpointArray = endpoints.split(","); String[] endpointArray = endpoints.split(",");
for (String endpoint : endpointArray) { for (String endpoint : endpointArray) {

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
@ -38,7 +40,7 @@ public class DecompressPdfController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/decompress-pdf", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/decompress-pdf", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Decompress PDF streams", summary = "Decompress PDF streams",
description = "Fully decompresses all PDF streams including text content") description = "Fully decompresses all PDF streams including text content")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
@ -50,7 +52,7 @@ public class ExtractImageScansController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/extract-image-scans") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-image-scans")
@Operation( @Operation(
summary = "Extract image scans from an input file", summary = "Extract image scans from an input file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage; import java.awt.image.RenderedImage;
@ -54,7 +56,7 @@ public class ExtractImagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/extract-images") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-images")
@Operation( @Operation(
summary = "Extract images from a PDF file", summary = "Extract images from a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.IOException; import java.io.IOException;
@ -38,7 +40,7 @@ public class FlattenController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/flatten") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/flatten")
@Operation( @Operation(
summary = "Flatten PDF form fields or full page", summary = "Flatten PDF form fields or full page",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.text.ParseException; import java.text.ParseException;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
@ -51,7 +53,7 @@ public class MetadataController {
binder.registerCustomEditor(Map.class, "allRequestParams", new StringToMapPropertyEditor()); binder.registerCustomEditor(Map.class, "allRequestParams", new StringToMapPropertyEditor());
} }
@PostMapping(consumes = "multipart/form-data", value = "/update-metadata") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/update-metadata")
@Operation( @Operation(
summary = "Update metadata of a PDF file", summary = "Update metadata of a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.*; import java.io.*;
import java.nio.file.Files; import java.nio.file.Files;
@ -76,7 +78,7 @@ public class OCRController {
.toList(); .toList();
} }
@PostMapping(consumes = "multipart/form-data", value = "/ocr-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/ocr-pdf")
@Operation( @Operation(
summary = "Process a PDF file with OCR", summary = "Process a PDF file with OCR",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -31,7 +33,7 @@ public class OverlayImageController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/add-image") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-image")
@Operation( @Operation(
summary = "Overlay image onto a PDF file", summary = "Overlay image onto a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@ -37,7 +39,7 @@ public class PageNumbersController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/add-page-numbers", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/add-page-numbers", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Add page numbers to a PDF document", summary = "Add page numbers to a PDF document",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.print.PageFormat; import java.awt.print.PageFormat;
@ -37,7 +39,7 @@ import stirling.software.SPDF.model.api.misc.PrintFileRequest;
public class PrintFileController { public class PrintFileController {
// TODO // TODO
// @PostMapping(value = "/print-file", consumes = "multipart/form-data") // @AutoJobPostMapping(value = "/print-file", consumes = "multipart/form-data")
// @Operation( // @Operation(
// summary = "Prints PDF/Image file to a set printer", // summary = "Prints PDF/Image file to a set printer",
// description = // description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -46,7 +48,7 @@ public class RepairController {
return endpointConfiguration.isGroupEnabled("qpdf"); return endpointConfiguration.isGroupEnabled("qpdf");
} }
@PostMapping(consumes = "multipart/form-data", value = "/repair") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/repair")
@Operation( @Operation(
summary = "Repair a PDF file", summary = "Repair a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
@ -27,7 +29,7 @@ public class ReplaceAndInvertColorController {
private final ReplaceAndInvertColorService replaceAndInvertColorService; private final ReplaceAndInvertColorService replaceAndInvertColorService;
@PostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf")
@Operation( @Operation(
summary = "Replace-Invert Color PDF", summary = "Replace-Invert Color PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.Color; import java.awt.Color;
import java.awt.Graphics2D; import java.awt.Graphics2D;
import java.awt.RenderingHints; import java.awt.RenderingHints;
@ -52,7 +54,7 @@ public class ScannerEffectController {
private static final int MAX_IMAGE_HEIGHT = 8192; private static final int MAX_IMAGE_HEIGHT = 8192;
private static final long MAX_IMAGE_PIXELS = 16_777_216; // 4096x4096 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( @Operation(
summary = "Apply scanner effect to PDF", summary = "Apply scanner effect to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Map; import java.util.Map;
@ -32,7 +34,7 @@ public class ShowJavascript {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/show-javascript") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/show-javascript")
@Operation( @Operation(
summary = "Grabs all JS from a PDF and returns a single JS file with all code", summary = "Grabs all JS from a PDF and returns a single JS file with all code",
description = "desc. Input:PDF Output:JS Type:SISO") description = "desc. Input:PDF Output:JS Type:SISO")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.File; import java.io.File;
@ -52,7 +54,7 @@ public class StampController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/add-stamp") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-stamp")
@Operation( @Operation(
summary = "Add stamp to a PDF file", summary = "Add stamp to a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
@ -37,7 +39,7 @@ public class UnlockPDFFormsController {
this.pdfDocumentFactory = pdfDocumentFactory; this.pdfDocumentFactory = pdfDocumentFactory;
} }
@PostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms")
@Operation( @Operation(
summary = "Remove read-only property from form fields", summary = "Remove read-only property from form fields",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.pipeline; package stirling.software.SPDF.controller.api.pipeline;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
import java.util.HashMap; import java.util.HashMap;
@ -46,7 +48,7 @@ public class PipelineController {
private final PostHogService postHogService; private final PostHogService postHogService;
@PostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @AutoJobPostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request) public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request)
throws JsonMappingException, JsonProcessingException { throws JsonMappingException, JsonProcessingException {
MultipartFile[] files = request.getFileInput(); MultipartFile[] files = request.getFileInput();

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.beans.PropertyEditorSupport; import java.beans.PropertyEditorSupport;
import java.io.*; import java.io.*;
@ -138,7 +140,7 @@ public class CertSignController {
} }
} }
@PostMapping( @AutoJobPostMapping(
consumes = { consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE MediaType.APPLICATION_FORM_URLENCODED_VALUE

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -188,7 +190,7 @@ public class GetInfoOnPDF {
return false; 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") @Operation(summary = "Summary here", description = "desc. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException { public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException {
MultipartFile inputFile = request.getFileInput(); MultipartFile inputFile = request.getFileInput();

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -32,7 +34,7 @@ public class PasswordController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-password") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-password")
@Operation( @Operation(
summary = "Remove password from a PDF file", summary = "Remove password from a PDF file",
description = 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( @Operation(
summary = "Add password to a PDF file", summary = "Add password to a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -56,7 +58,7 @@ public class RedactController {
List.class, "redactions", new StringToArrayListPropertyEditor()); List.class, "redactions", new StringToArrayListPropertyEditor());
} }
@PostMapping(value = "/redact", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/redact", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Redacts areas and pages in a PDF document", summary = "Redacts areas and pages in a PDF document",
description = description =
@ -190,7 +192,7 @@ public class RedactController {
return pageNumbers; return pageNumbers;
} }
@PostMapping(value = "/auto-redact", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/auto-redact", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Redacts listOfText in a PDF document", summary = "Redacts listOfText in a PDF document",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.util.List; import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -32,7 +34,7 @@ public class RemoveCertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign")
@Operation( @Operation(
summary = "Remove digital signature from PDF", summary = "Remove digital signature from PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.cos.COSDictionary;
@ -46,7 +48,7 @@ public class SanitizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf")
@Operation( @Operation(
summary = "Sanitize a PDF file", summary = "Sanitize a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.beans.PropertyEditorSupport; import java.beans.PropertyEditorSupport;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
@ -69,7 +71,7 @@ public class ValidateSignatureController {
description = description =
"Validates the digital signatures in a PDF file against default or custom" "Validates the digital signatures in a PDF file against default or custom"
+ " certificates. Input:PDF Output:JSON Type:SISO") + " 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<List<SignatureValidationResult>> validateSignature( public ResponseEntity<List<SignatureValidationResult>> validateSignature(
@ModelAttribute SignatureValidationRequest request) throws IOException { @ModelAttribute SignatureValidationRequest request) throws IOException {
List<SignatureValidationResult> results = new ArrayList<>(); List<SignatureValidationResult> results = new ArrayList<>();

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.beans.PropertyEditorSupport; 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( @Operation(
summary = "Add watermark to a PDF file", summary = "Add watermark to a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.model.api.converters; package stirling.software.SPDF.model.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -18,7 +20,7 @@ import stirling.software.common.util.PDFToFile;
@RequestMapping("/api/v1/convert") @RequestMapping("/api/v1/convert")
public class ConvertPDFToMarkdown { public class ConvertPDFToMarkdown {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/markdown") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/markdown")
@Operation( @Operation(
summary = "Convert PDF to Markdown", summary = "Convert PDF to Markdown",
description = description =

View File

@ -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<AuditDashboardData> 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<LoginData> getLoginData() {
LoginData data = new LoginData();
Map<String, String> 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<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> 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<SessionEntity> 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<User> 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<Team> 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<AccountData> 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> 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<TeamsData> getTeamsData() {
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
List<TeamWithUserCountDTO> teamsWithCounts =
allTeamsWithCounts.stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
.toList();
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
Map<Long, Date> 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<TeamDetailsData> 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<User> teamUsers = userRepository.findAllByTeamId(id);
List<User> allUsers = userRepository.findAllWithTeam();
List<User> 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<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
Map<String, Date> 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<DatabaseData> getDatabaseData() {
List<FileInfo> 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<String, String> providerList;
private String loginMethod;
private boolean altLogin;
}
@Data
public static class AdminSettingsData {
private List<User> users;
private String currentUsername;
private Map<String, String> roleDetails;
private Map<String, Boolean> userSessions;
private Map<String, Date> userLastRequest;
private int totalUsers;
private int activeUsers;
private int disabledUsers;
private List<Team> 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<TeamWithUserCountDTO> teamsWithCounts;
private Map<Long, Date> teamLastRequest;
}
@Data
public static class TeamDetailsData {
private Team team;
private List<User> teamUsers;
private List<User> availableUsers;
private Map<String, Date> userLastRequest;
}
@Data
public static class DatabaseData {
private List<FileInfo> backupFiles;
private String databaseVersion;
private boolean versionUnknown;
}
}

View File

@ -1,5 +1,7 @@
package stirling.software.proprietary.security.controller.api; package stirling.software.proprietary.security.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -42,7 +44,7 @@ public class EmailController {
* attachment. * attachment.
* @return ResponseEntity with success or error message. * @return ResponseEntity with success or error message.
*/ */
@PostMapping(consumes = "multipart/form-data", value = "/send-email") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/send-email")
@Operation( @Operation(
summary = "Send an email with an attachment", summary = "Send an email with an attachment",
description = description =

View File

@ -26,8 +26,6 @@ services:
DISABLE_ADDITIONAL_FEATURES: "false" DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "false" SECURITY_ENABLELOGIN: "false"
FAT_DOCKER: "true" 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 SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Full-featured Stirling-PDF with all capabilities UI_HOMEDESCRIPTION: Full-featured Stirling-PDF with all capabilities

View File

@ -25,7 +25,6 @@ services:
environment: environment:
DISABLE_ADDITIONAL_FEATURES: "true" DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false" 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 SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest

View File

@ -52,6 +52,44 @@ http {
proxy_request_buffering off; 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 # Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;

3
frontend/.gitignore vendored
View File

@ -22,3 +22,6 @@
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
playwright-report
test-results

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,13 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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": { "eslintConfig": {
"extends": [ "extends": [
@ -58,15 +64,19 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.40.0",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0", "@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^1.0.0",
"jsdom": "^23.0.0",
"license-checker": "^25.0.1", "license-checker": "^25.0.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5" "vite": "^6.3.5",
"vitest": "^1.0.0"
} }
} }

View File

@ -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,
},
});

View File

@ -347,6 +347,10 @@
"title": "Rotate", "title": "Rotate",
"desc": "Easily rotate your PDFs." "desc": "Easily rotate your PDFs."
}, },
"convert": {
"title": "Convert",
"desc": "Convert files between different formats"
},
"imageToPdf": { "imageToPdf": {
"title": "Image to PDF", "title": "Image to PDF",
"desc": "Convert a image (PNG, JPEG, GIF) to PDF." "desc": "Convert a image (PNG, JPEG, GIF) to PDF."
@ -579,6 +583,10 @@
"title": "Validate PDF Signature", "title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents" "desc": "Verify digital signatures and certificates in PDF documents"
}, },
"swagger": {
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"replaceColorPdf": { "replaceColorPdf": {
"title": "Advanced Colour options", "title": "Advanced Colour options",
"desc": "Replace colour for text and background in PDF and invert full colour of pdf to reduce file size" "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):", "selectAngle": "Select rotation angle (in multiples of 90 degrees):",
"submit": "Rotate" "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": { "imageToPdf": {
"tags": "conversion,img,jpg,picture,photo" "tags": "conversion,img,jpg,picture,photo"
}, },
@ -1562,6 +1637,12 @@
}, },
"note": "Release notes are only available in English" "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": { "cookieBanner": {
"popUp": { "popUp": {
"title": "How we use Cookies", "title": "How we use Cookies",
@ -1613,18 +1694,6 @@
"pageEditor": "Page Editor", "pageEditor": "Page Editor",
"fileManager": "File Manager" "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": { "pageEditor": {
"title": "Page Editor", "title": "Page Editor",
"save": "Save Changes", "save": "Save Changes",
@ -1696,7 +1765,16 @@
"failedToLoad": "Failed to load file to active set.", "failedToLoad": "Failed to load file to active set.",
"storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.", "storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.",
"clearAll": "Clear All", "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": { "storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",

View File

@ -575,6 +575,10 @@
"title": "Validate PDF Signature", "title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents" "desc": "Verify digital signatures and certificates in PDF documents"
}, },
"swagger": {
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"replaceColorPdf": { "replaceColorPdf": {
"title": "Replace and Invert Color", "title": "Replace and Invert Color",
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size" "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" "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": { "cookieBanner": {
"popUp": { "popUp": {
"title": "How we use Cookies", "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." "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."
} }
} }

View File

@ -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"
}
}

View File

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import { FileContextProvider } from './contexts/FileContext'; import { FileContextProvider } from './contexts/FileContext';
import { FilesModalProvider } from './contexts/FilesModalContext';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
// Import global styles // Import global styles
@ -11,7 +12,9 @@ export default function App() {
return ( return (
<RainbowThemeProvider> <RainbowThemeProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}> <FileContextProvider enableUrlSync={true} enablePersistence={true}>
<HomePage /> <FilesModalProvider>
<HomePage />
</FilesModalProvider>
</FileContextProvider> </FileContextProvider>
</RainbowThemeProvider> </RainbowThemeProvider>
); );

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@ import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage'; import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService'; import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import styles from '../pageEditor/PageEditor.module.css'; import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from '../pageEditor/FileThumbnail'; import FileThumbnail from '../pageEditor/FileThumbnail';
import DragDropGrid from '../pageEditor/DragDropGrid'; import DragDropGrid from '../pageEditor/DragDropGrid';
@ -34,6 +35,7 @@ interface FileEditorProps {
toolMode?: boolean; toolMode?: boolean;
showUpload?: boolean; showUpload?: boolean;
showBulkActions?: boolean; showBulkActions?: boolean;
supportedExtensions?: string[];
} }
const FileEditor = ({ const FileEditor = ({
@ -41,10 +43,17 @@ const FileEditor = ({
onMergeFiles, onMergeFiles,
toolMode = false, toolMode = false,
showUpload = true, showUpload = true,
showBulkActions = true showBulkActions = true,
supportedExtensions = ["pdf"]
}: FileEditorProps) => { }: FileEditorProps) => {
const { t } = useTranslation(); 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 // Get file context
const fileContext = useFileContext(); const fileContext = useFileContext();
const { const {
@ -224,49 +233,46 @@ const FileEditor = ({
// Handle PDF files normally // Handle PDF files normally
allExtractedFiles.push(file); allExtractedFiles.push(file);
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) { } 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 { try {
// Validate ZIP file first // Validate ZIP file first
const validation = await zipFileService.validateZipFile(file); const validation = await zipFileService.validateZipFile(file);
if (!validation.isValid) {
errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`);
continue;
}
// Extract PDF files from ZIP if (validation.isValid && validation.containsPDFs) {
setZipExtractionProgress({ // ZIP contains PDFs - extract them
isExtracting: true,
currentFile: file.name,
progress: 0,
extractedCount: 0,
totalFiles: validation.fileCount
});
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
setZipExtractionProgress({ setZipExtractionProgress({
isExtracting: true, isExtracting: true,
currentFile: progress.currentFile, currentFile: file.name,
progress: progress.progress, progress: 0,
extractedCount: progress.extractedCount, extractedCount: 0,
totalFiles: progress.totalFiles totalFiles: validation.fileCount
}); });
});
// Reset extraction progress const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
setZipExtractionProgress({ setZipExtractionProgress({
isExtracting: false, isExtracting: true,
currentFile: '', currentFile: progress.currentFile,
progress: 0, progress: progress.progress,
extractedCount: 0, extractedCount: progress.extractedCount,
totalFiles: 0 totalFiles: progress.totalFiles
}); });
});
if (extractionResult.success) { // Reset extraction progress
allExtractedFiles.push(...extractionResult.extractedFiles); setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
// Record ZIP extraction operation if (extractionResult.success) {
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; allExtractedFiles.push(...extractionResult.extractedFiles);
const operation: FileOperation = {
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId, id: operationId,
type: 'convert', type: 'convert',
timestamp: Date.now(), timestamp: Date.now(),
@ -290,8 +296,13 @@ const FileEditor = ({
if (extractionResult.errors.length > 0) { if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors); errors.push(...extractionResult.errors);
} }
} else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
}
} else { } 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) { } catch (zipError) {
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`); errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
@ -304,7 +315,8 @@ const FileEditor = ({
}); });
} }
} else { } 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 ( return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}> <Dropzone
<LoadingOverlay visible={false} /> onDrop={handleFileUpload}
accept={["*/*"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{
height: '100vh',
border: 'none',
borderRadius: 0,
backgroundColor: 'transparent'
}}
activateOnClick={false}
activateOnDrag={true}
>
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={false} />
<Box p="md" pt="xl"> <Box p="md" pt="xl">
<Group mb="md"> <Group mb="md">
{showBulkActions && !toolMode && ( {showBulkActions && !toolMode && (
<> <>
<Button onClick={selectAll} variant="light">Select All</Button> <Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button> <Button onClick={deselectAll} variant="light">Deselect All</Button>
<Button onClick={closeAllFiles} variant="light" color="orange"> <Button onClick={closeAllFiles} variant="light" color="orange">
Close All Close All
</Button>
</>
)}
{/* Load from storage and upload buttons */}
{showUpload && (
<>
<Button
variant="outline"
color="blue"
onClick={() => setShowFilePickerModal(true)}
>
Load from Storage
</Button>
<Dropzone
onDrop={handleFileUpload}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{ display: 'contents' }}
>
<Button variant="outline" color="green">
Upload Files
</Button> </Button>
</Dropzone> </>
</> )}
)} </Group>
</Group>
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? ( {files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
@ -804,6 +805,7 @@ const FileEditor = ({
onSplitFile={handleSplitFile} onSplitFile={handleSplitFile}
onSetStatus={setStatus} onSetStatus={setStatus}
toolMode={toolMode} toolMode={toolMode}
isSupported={isFileSupported(file.name)}
/> />
)} )}
renderSplitMarker={(file, index) => ( renderSplitMarker={(file, index) => (
@ -853,7 +855,8 @@ const FileEditor = ({
{error} {error}
</Notification> </Notification>
)} )}
</Box> </Box>
</Dropzone>
); );
}; };

View File

@ -18,9 +18,10 @@ interface FileCardProps {
onEdit?: () => void; onEdit?: () => void;
isSelected?: boolean; isSelected?: boolean;
onSelect?: () => void; 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 { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file); const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@ -35,15 +36,18 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
width: 225, width: 225,
minWidth: 180, minWidth: 180,
maxWidth: 260, maxWidth: 260,
cursor: onDoubleClick ? "pointer" : undefined, cursor: onDoubleClick && isSupported ? "pointer" : undefined,
position: 'relative', position: 'relative',
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined, 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} onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
onClick={onSelect} onClick={onSelect}
data-testid="file-card"
> >
<Stack gap={6} align="center"> <Stack gap={6} align="center">
<Box <Box
@ -179,6 +183,11 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
DB DB
</Badge> </Badge>
)} )}
{!isSupported && (
<Badge color="orange" variant="filled" size="sm">
{t("fileManager.unsupported", "Unsupported")}
</Badge>
)}
</Group> </Group>
<Button <Button

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core'; import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from '@mui/icons-material/History';
@ -37,6 +38,7 @@ interface FileThumbnailProps {
onViewFile: (fileId: string) => void; onViewFile: (fileId: string) => void;
onSetStatus: (status: string) => void; onSetStatus: (status: string) => void;
toolMode?: boolean; toolMode?: boolean;
isSupported?: boolean;
} }
const FileThumbnail = ({ const FileThumbnail = ({
@ -60,7 +62,9 @@ const FileThumbnail = ({
onViewFile, onViewFile,
onSetStatus, onSetStatus,
toolMode = false, toolMode = false,
isSupported = true,
}: FileThumbnailProps) => { }: FileThumbnailProps) => {
const { t } = useTranslation();
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
@ -81,6 +85,7 @@ const FileThumbnail = ({
} }
}} }}
data-file-id={file.id} data-file-id={file.id}
data-testid="file-thumbnail"
className={` className={`
${styles.pageContainer} ${styles.pageContainer}
!rounded-lg !rounded-lg
@ -106,7 +111,9 @@ const FileThumbnail = ({
} }
return 'translateX(0)'; return 'translateX(0)';
})(), })(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out' transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
opacity: isSupported ? 1 : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)'
}} }}
draggable draggable
onDragStart={() => onDragStart(file.id)} onDragStart={() => onDragStart(file.id)}
@ -119,6 +126,7 @@ const FileThumbnail = ({
{selectionMode && ( {selectionMode && (
<div <div
className={styles.checkboxContainer} className={styles.checkboxContainer}
data-testid="file-thumbnail-checkbox"
style={{ style={{
position: 'absolute', position: 'absolute',
top: 8, top: 8,
@ -140,9 +148,12 @@ const FileThumbnail = ({
checked={selectedFiles.includes(file.id)} checked={selectedFiles.includes(file.id)}
onChange={(event) => { onChange={(event) => {
event.stopPropagation(); event.stopPropagation();
onToggleFile(file.id); if (isSupported) {
onToggleFile(file.id);
}
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
disabled={!isSupported}
size="sm" size="sm"
/> />
</div> </div>
@ -193,6 +204,23 @@ const FileThumbnail = ({
{file.pageCount} pages {file.pageCount} pages
</Badge> </Badge>
{/* Unsupported badge */}
{!isSupported && (
<Badge
size="sm"
variant="filled"
color="orange"
style={{
position: 'absolute',
top: 8,
right: selectionMode ? 48 : 8, // Avoid overlap with checkbox
zIndex: 3,
}}
>
{t("fileManager.unsupported", "Unsupported")}
</Badge>
)}
{/* File name overlay */} {/* File name overlay */}
<Text <Text
className={styles.pageNumber} className={styles.pageNumber}
@ -238,7 +266,7 @@ const FileThumbnail = ({
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
}} }}
> >
{!toolMode && ( {!toolMode && isSupported && (
<> <>
<Tooltip label="View File"> <Tooltip label="View File">
<ActionIcon <ActionIcon

View File

@ -0,0 +1,237 @@
import React, { ReactNode, useState, useMemo } from 'react';
import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from '@mantine/core';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import SearchIcon from '@mui/icons-material/Search';
export interface DropdownItem {
value: string;
name: string;
leftIcon?: ReactNode;
disabled?: boolean;
}
export interface DropdownListWithFooterProps {
// Value and onChange - support both single and multi-select
value: string | string[];
onChange: (value: string | string[]) => void;
// Items and display
items: DropdownItem[];
placeholder?: string;
disabled?: boolean;
// Labels and headers
label?: string;
header?: ReactNode;
footer?: ReactNode;
// Behavior
multiSelect?: boolean;
searchable?: boolean;
maxHeight?: number;
// Styling
className?: string;
dropdownClassName?: string;
// Popover props
position?: 'top' | 'bottom' | 'left' | 'right';
withArrow?: boolean;
width?: 'target' | number;
}
const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
value,
onChange,
items,
placeholder = 'Select option',
disabled = false,
label,
header,
footer,
multiSelect = false,
searchable = false,
maxHeight = 300,
className = '',
dropdownClassName = '',
position = 'bottom',
withArrow = false,
width = 'target'
}) => {
const [searchTerm, setSearchTerm] = useState('');
const isMultiValue = Array.isArray(value);
const selectedValues = isMultiValue ? value : (value ? [value] : []);
// Filter items based on search term
const filteredItems = useMemo(() => {
if (!searchable || !searchTerm.trim()) {
return items;
}
return items.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [items, searchTerm, searchable]);
const handleItemClick = (itemValue: string) => {
if (multiSelect) {
const newSelection = selectedValues.includes(itemValue)
? selectedValues.filter(v => v !== itemValue)
: [...selectedValues, itemValue];
onChange(newSelection);
} else {
onChange(itemValue);
}
};
const getDisplayText = () => {
if (selectedValues.length === 0) {
return placeholder;
} else if (selectedValues.length === 1) {
const selectedItem = items.find(item => item.value === selectedValues[0]);
return selectedItem?.name || selectedValues[0];
} else {
return `${selectedValues.length} selected`;
}
};
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.currentTarget.value);
};
return (
<Box className={className}>
{label && (
<Text size="sm" fw={500} mb={4}>
{label}
</Text>
)}
<Popover
width={width}
position={position}
withArrow={withArrow}
shadow="md"
onClose={() => searchable && setSearchTerm('')}
>
<Popover.Target>
<Box
style={{
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
borderRadius: 'var(--mantine-radius-sm)',
padding: '8px 12px',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
opacity: disabled ? 0.6 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
minHeight: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<Text size="sm" style={{ flex: 1 }}>
{getDisplayText()}
</Text>
<UnfoldMoreIcon style={{
fontSize: '1rem',
color: 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2))'
}} />
</Box>
</Popover.Target>
<Popover.Dropdown className={dropdownClassName}>
<Stack gap="xs">
{header && (
<Box style={{
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
paddingBottom: '8px'
}}>
{header}
</Box>
)}
{searchable && (
<Box style={{
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
paddingBottom: '8px'
}}>
<TextInput
placeholder="Search..."
value={searchTerm}
onChange={handleSearchChange}
leftSection={<SearchIcon style={{ fontSize: '1rem' }} />}
size="sm"
style={{ width: '100%' }}
/>
</Box>
)}
<Box style={{ maxHeight, overflowY: 'auto' }}>
{filteredItems.length === 0 ? (
<Box style={{ padding: '12px', textAlign: 'center' }}>
<Text size="sm" c="dimmed">
{searchable && searchTerm ? 'No results found' : 'No items available'}
</Text>
</Box>
) : (
filteredItems.map((item) => (
<Box
key={item.value}
onClick={() => !item.disabled && handleItemClick(item.value)}
style={{
padding: '8px 12px',
cursor: item.disabled ? 'not-allowed' : 'pointer',
borderRadius: 'var(--mantine-radius-sm)',
opacity: item.disabled ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
onMouseEnter={(e) => {
if (!item.disabled) {
e.currentTarget.style.backgroundColor = 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<Group gap="sm" style={{ flex: 1 }}>
{item.leftIcon && (
<Box style={{ display: 'flex', alignItems: 'center' }}>
{item.leftIcon}
</Box>
)}
<Text size="sm">{item.name}</Text>
</Group>
{multiSelect && (
<Checkbox
checked={selectedValues.includes(item.value)}
onChange={() => {}} // Handled by parent onClick
size="sm"
disabled={item.disabled}
/>
)}
</Box>
))
)}
</Box>
{footer && (
<Box style={{
borderTop: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
paddingTop: '8px'
}}>
{footer}
</Box>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Box>
);
};
export default DropdownListWithFooter;

View File

@ -20,6 +20,7 @@ interface FileGridProps {
onShowAll?: () => void; onShowAll?: () => void;
showingAll?: boolean; showingAll?: boolean;
onDeleteAll?: () => void; onDeleteAll?: () => void;
isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported
} }
type SortOption = 'date' | 'name' | 'size'; type SortOption = 'date' | 'name' | 'size';
@ -37,7 +38,8 @@ const FileGrid = ({
maxDisplay, maxDisplay,
onShowAll, onShowAll,
showingAll = false, showingAll = false,
onDeleteAll onDeleteAll,
isFileSupported
}: FileGridProps) => { }: FileGridProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@ -123,16 +125,18 @@ const FileGrid = ({
{displayFiles.map((file, idx) => { {displayFiles.map((file, idx) => {
const fileId = file.id || file.name; const fileId = file.id || file.name;
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId); const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
const supported = isFileSupported ? isFileSupported(file.name) : true;
return ( return (
<FileCard <FileCard
key={fileId + idx} key={fileId + idx}
file={file} file={file}
onRemove={onRemove ? () => onRemove(originalIdx) : undefined} onRemove={onRemove ? () => onRemove(originalIdx) : undefined}
onDoubleClick={onDoubleClick ? () => onDoubleClick(file) : undefined} onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
onView={onView ? () => onView(file) : undefined} onView={onView && supported ? () => onView(file) : undefined}
onEdit={onEdit ? () => onEdit(file) : undefined} onEdit={onEdit && supported ? () => onEdit(file) : undefined}
isSelected={selectedFiles.includes(fileId)} isSelected={selectedFiles.includes(fileId)}
onSelect={onSelect ? () => onSelect(fileId) : undefined} onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
isSupported={supported}
/> />
); );
})} })}

View File

@ -0,0 +1,36 @@
import React from 'react';
import { Modal } from '@mantine/core';
import FileUploadSelector from './FileUploadSelector';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { Tool } from '../../types/tool';
interface FileUploadModalProps {
selectedTool?: Tool | null;
}
const FileUploadModal: React.FC<FileUploadModalProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext();
return (
<Modal
opened={isFilesModalOpen}
onClose={closeFilesModal}
title="Upload Files"
size="xl"
centered
>
<FileUploadSelector
title="Upload Files"
subtitle="Choose files from storage or upload new files"
onFileSelect={onFileSelect}
onFilesSelect={onFilesSelect}
accept={["*/*"]}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
data-testid="file-upload-modal"
/>
</Modal>
);
};
export default FileUploadModal;

View File

@ -5,6 +5,7 @@ import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { fileStorage } from '../../services/fileStorage'; import { fileStorage } from '../../services/fileStorage';
import { FileWithUrl } from '../../types/file'; import { FileWithUrl } from '../../types/file';
import { detectFileExtension } from '../../utils/fileUtils';
import FileGrid from './FileGrid'; import FileGrid from './FileGrid';
import MultiSelectControls from './MultiSelectControls'; import MultiSelectControls from './MultiSelectControls';
import { useFileManager } from '../../hooks/useFileManager'; import { useFileManager } from '../../hooks/useFileManager';
@ -20,6 +21,7 @@ interface FileUploadSelectorProps {
onFileSelect?: (file: File) => void; onFileSelect?: (file: File) => void;
onFilesSelect: (files: File[]) => void; onFilesSelect: (files: File[]) => void;
accept?: string[]; accept?: string[];
supportedExtensions?: string[]; // Extensions this tool supports (e.g., ['pdf', 'jpg', 'png'])
// Loading state // Loading state
loading?: boolean; loading?: boolean;
@ -38,6 +40,7 @@ const FileUploadSelector = ({
onFileSelect, onFileSelect,
onFilesSelect, onFilesSelect,
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"], accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
supportedExtensions = ["pdf"], // Default to PDF only for most tools
loading = false, loading = false,
disabled = false, disabled = false,
showRecentFiles = true, showRecentFiles = true,
@ -51,6 +54,12 @@ const FileUploadSelector = ({
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager(); const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]);
const refreshRecentFiles = useCallback(async () => { const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles(); const files = await loadRecentFiles();
setRecentFiles(files); setRecentFiles(files);
@ -155,6 +164,7 @@ const FileUploadSelector = ({
disabled={disabled || loading} disabled={disabled || loading}
style={{ width: '100%', height: "5rem" }} style={{ width: '100%', height: "5rem" }}
activateOnClick={true} activateOnClick={true}
data-testid="file-dropzone"
> >
<Center> <Center>
<Stack align="center" gap="sm"> <Stack align="center" gap="sm">
@ -192,6 +202,7 @@ const FileUploadSelector = ({
accept={accept.join(',')} accept={accept.join(',')}
onChange={handleFileInputChange} onChange={handleFileInputChange}
style={{ display: 'none' }} style={{ display: 'none' }}
data-testid="file-input"
/> />
</Stack> </Stack>
)} )}
@ -225,6 +236,7 @@ const FileUploadSelector = ({
selectedFiles={selectedFiles} selectedFiles={selectedFiles}
showSearch={true} showSearch={true}
showSort={true} showSort={true}
isFileSupported={isFileSupported}
onDeleteAll={async () => { onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => { await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name); await fileStorage.deleteFile(file.id || file.name);

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Container, Stack, Text, Button } from '@mantine/core';
import FolderIcon from '@mui/icons-material/FolderRounded';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
interface LandingPageProps {
title: string;
}
const LandingPage = ({ title }: LandingPageProps) => {
const { openFilesModal } = useFilesModalContext();
return (
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="lg">
<Text size="xl" fw={500} c="dimmed">
{title}
</Text>
<Button
leftSection={<FolderIcon />}
size="lg"
onClick={openFilesModal}
>
Open Files
</Button>
</Stack>
</Container>
);
};
export default LandingPage;

View File

@ -44,7 +44,7 @@
/* Dark theme support */ /* Dark theme support */
[data-mantine-color-scheme="dark"] .languageItem { [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) { [data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) {
@ -52,11 +52,11 @@
} }
[data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) { [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) { [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 */ /* Responsive text visibility */

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; 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 { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n'; import { supportedLanguages } from '../../i18n';
import LanguageIcon from '@mui/icons-material/Language'; import LanguageIcon from '@mui/icons-material/Language';
@ -7,8 +7,6 @@ import styles from './LanguageSelector.module.css';
const LanguageSelector = () => { const LanguageSelector = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false); const [animationTriggered, setAnimationTriggered] = useState(false);
const [isChanging, setIsChanging] = useState(false); const [isChanging, setIsChanging] = useState(false);
@ -102,10 +100,10 @@ const LanguageSelector = () => {
styles={{ styles={{
root: { root: {
border: 'none', 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)', transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': { '&: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: { label: {
@ -125,7 +123,8 @@ const LanguageSelector = () => {
padding: '12px', padding: '12px',
borderRadius: '8px', borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', 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))',
}} }}
> >
<ScrollArea h={190} type="scroll"> <ScrollArea h={190} type="scroll">
@ -145,6 +144,7 @@ const LanguageSelector = () => {
size="sm" size="sm"
fullWidth fullWidth
onClick={(event) => handleLanguageChange(option.value, event)} onClick={(event) => handleLanguageChange(option.value, event)}
data-selected={option.value === i18n.language}
styles={{ styles={{
root: { root: {
borderRadius: '4px', borderRadius: '4px',
@ -153,21 +153,17 @@ const LanguageSelector = () => {
justifyContent: 'flex-start', justifyContent: 'flex-start',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: option.value === i18n.language ? ( backgroundColor: option.value === i18n.language
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1] ? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
) : 'transparent', : 'transparent',
color: option.value === i18n.language ? ( color: option.value === i18n.language
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7] ? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
) : ( : 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
),
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': { '&:hover': {
backgroundColor: option.value === i18n.language ? ( backgroundColor: option.value === i18n.language
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2] ? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
) : ( : 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
),
transform: 'translateY(-1px)', transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
} }
@ -197,7 +193,7 @@ const LanguageSelector = () => {
width: 0, width: 0,
height: 0, height: 0,
borderRadius: '50%', borderRadius: '50%',
backgroundColor: theme.colors.blue[4], backgroundColor: 'var(--mantine-color-blue-4)',
opacity: 0.6, opacity: 0.6,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)', animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',

View File

@ -11,6 +11,7 @@ import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css'; import rainbowStyles from '../../styles/rainbow.module.css';
import AppConfigModal from './AppConfigModal'; import AppConfigModal from './AppConfigModal';
import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useIsOverflowing } from '../../hooks/useIsOverflowing';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import './QuickAccessBar.css'; import './QuickAccessBar.css';
interface QuickAccessBarProps { interface QuickAccessBarProps {
@ -30,6 +31,7 @@ interface ButtonConfig {
isRound?: boolean; isRound?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl'; size?: 'sm' | 'md' | 'lg' | 'xl';
onClick: () => void; onClick: () => void;
type?: 'navigation' | 'modal' | 'action'; // navigation = main nav, modal = triggers modal, action = other actions
} }
function NavHeader({ function NavHeader({
@ -111,11 +113,16 @@ const QuickAccessBar = ({
readerMode, readerMode,
}: QuickAccessBarProps) => { }: QuickAccessBarProps) => {
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const [configModalOpen, setConfigModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools'); const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null); const scrollableRef = useRef<HTMLDivElement>(null);
const isOverflow = useIsOverflowing(scrollableRef); const isOverflow = useIsOverflowing(scrollableRef);
const handleFilesButtonClick = () => {
openFilesModal();
};
const buttonConfigs: ButtonConfig[] = [ const buttonConfigs: ButtonConfig[] = [
{ {
id: 'read', id: 'read',
@ -124,6 +131,7 @@ const QuickAccessBar = ({
tooltip: 'Read documents', tooltip: 'Read documents',
size: 'lg', size: 'lg',
isRound: false, isRound: false,
type: 'navigation',
onClick: () => { onClick: () => {
setActiveButton('read'); setActiveButton('read');
onReaderToggle(); onReaderToggle();
@ -139,6 +147,7 @@ const QuickAccessBar = ({
tooltip: 'Sign your document', tooltip: 'Sign your document',
size: 'lg', size: 'lg',
isRound: false, isRound: false,
type: 'navigation',
onClick: () => setActiveButton('sign') onClick: () => setActiveButton('sign')
}, },
{ {
@ -151,6 +160,7 @@ const QuickAccessBar = ({
tooltip: 'Automate workflows', tooltip: 'Automate workflows',
size: 'lg', size: 'lg',
isRound: false, isRound: false,
type: 'navigation',
onClick: () => setActiveButton('automate') onClick: () => setActiveButton('automate')
}, },
{ {
@ -160,7 +170,8 @@ const QuickAccessBar = ({
tooltip: 'Manage files', tooltip: 'Manage files',
isRound: true, isRound: true,
size: 'lg', size: 'lg',
onClick: () => setActiveButton('files') type: 'modal',
onClick: handleFilesButtonClick
}, },
{ {
id: 'activity', id: 'activity',
@ -172,6 +183,7 @@ const QuickAccessBar = ({
tooltip: 'View activity and analytics', tooltip: 'View activity and analytics',
isRound: true, isRound: true,
size: 'lg', size: 'lg',
type: 'navigation',
onClick: () => setActiveButton('activity') onClick: () => setActiveButton('activity')
}, },
{ {
@ -180,6 +192,7 @@ const QuickAccessBar = ({
icon: <SettingsIcon sx={{ fontSize: "1rem" }} />, icon: <SettingsIcon sx={{ fontSize: "1rem" }} />,
tooltip: 'Configure settings', tooltip: 'Configure settings',
size: 'lg', size: 'lg',
type: 'modal',
onClick: () => { onClick: () => {
setConfigModalOpen(true); setConfigModalOpen(true);
} }
@ -193,8 +206,16 @@ const QuickAccessBar = ({
return config.isRound ? CIRCULAR_BORDER_RADIUS : ROUND_BORDER_RADIUS; 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 getButtonStyle = (config: ButtonConfig) => {
const isActive = activeButton === config.id; const isActive = isButtonActive(config);
if (isActive) { if (isActive) {
return { return {
@ -205,7 +226,7 @@ const QuickAccessBar = ({
}; };
} }
// Inactive state - use consistent inactive colors // Inactive state for all buttons
return { return {
backgroundColor: 'var(--icon-inactive-bg)', backgroundColor: 'var(--icon-inactive-bg)',
color: 'var(--icon-inactive-color)', color: 'var(--icon-inactive-color)',
@ -260,13 +281,14 @@ const QuickAccessBar = ({
variant="subtle" variant="subtle"
onClick={config.onClick} onClick={config.onClick}
style={getButtonStyle(config)} style={getButtonStyle(config)}
className={activeButton === config.id ? 'activeIconScale' : ''} className={isButtonActive(config) ? 'activeIconScale' : ''}
data-testid={`${config.id}-button`}
> >
<span className="iconContainer"> <span className="iconContainer">
{config.icon} {config.icon}
</span> </span>
</ActionIcon> </ActionIcon>
<span className={`button-text ${activeButton === config.id ? 'active' : 'inactive'}`}> <span className={`button-text ${isButtonActive(config) ? 'active' : 'inactive'}`}>
{config.name} {config.name}
</span> </span>
</div> </div>
@ -287,30 +309,29 @@ const QuickAccessBar = ({
<div className="spacer" /> <div className="spacer" />
{/* Config button at the bottom */} {/* Config button at the bottom */}
<Tooltip label="Configure settings" position="right"> {buttonConfigs
<div className="flex flex-col items-center gap-1"> .filter(config => config.id === 'config')
<ActionIcon .map(config => (
size="lg" <Tooltip key={config.id} label={config.tooltip} position="right">
variant="subtle" <div className="flex flex-col items-center gap-1">
onClick={() => { <ActionIcon
setConfigModalOpen(true); size={config.size || 'lg'}
}} variant="subtle"
style={{ onClick={config.onClick}
backgroundColor: 'var(--icon-inactive-bg)', style={getButtonStyle(config)}
color: 'var(--icon-inactive-color)', className={isButtonActive(config) ? 'activeIconScale' : ''}
border: 'none', data-testid={`${config.id}-button`}
borderRadius: '8px', >
}} <span className="iconContainer">
> {config.icon}
<span className="iconContainer"> </span>
<SettingsIcon sx={{ fontSize: "1rem" }} /> </ActionIcon>
</span> <span className={`button-text ${isButtonActive(config) ? 'active' : 'inactive'}`}>
</ActionIcon> {config.name}
<span className="config-button-text"> </span>
Config </div>
</span> </Tooltip>
</div> ))}
</Tooltip>
</div> </div>
</div> </div>

View File

@ -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 (
<Stack gap="sm" data-testid="email-settings">
<Text size="sm" fw={500}>{t("convert.emailOptions", "Email to PDF Options")}:</Text>
<Checkbox
label={t("convert.includeAttachments", "Include email attachments")}
checked={parameters.emailOptions.includeAttachments}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAttachments: event.currentTarget.checked
})}
disabled={disabled}
data-testid="include-attachments-checkbox"
/>
{parameters.emailOptions.includeAttachments && (
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.maxAttachmentSize", "Maximum attachment size (MB)")}:</Text>
<NumberInput
value={parameters.emailOptions.maxAttachmentSizeMB}
onChange={(value) => onParameterChange('emailOptions', {
...parameters.emailOptions,
maxAttachmentSizeMB: Number(value) || 10
})}
min={1}
max={100}
step={1}
disabled={disabled}
data-testid="max-attachment-size-input"
/>
</Stack>
)}
<Checkbox
label={t("convert.includeAllRecipients", "Include CC and BCC recipients in header")}
checked={parameters.emailOptions.includeAllRecipients}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAllRecipients: event.currentTarget.checked
})}
disabled={disabled}
data-testid="include-all-recipients-checkbox"
/>
<Checkbox
label={t("convert.downloadHtml", "Download HTML intermediate file instead of PDF")}
checked={parameters.emailOptions.downloadHtml}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
downloadHtml: event.currentTarget.checked
})}
disabled={disabled}
data-testid="download-html-checkbox"
/>
</Stack>
);
};
export default ConvertFromEmailSettings;

View File

@ -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 (
<Stack gap="sm" data-testid="pdf-options-section">
<Text size="sm" fw={500}>{t("convert.pdfOptions", "PDF Options")}:</Text>
<Select
data-testid="color-type-select"
label={t("convert.colorType", "Color Type")}
value={parameters.imageOptions.colorType}
onChange={(val) => val && onParameterChange('imageOptions', {
...parameters.imageOptions,
colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES]
})}
data={[
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
]}
disabled={disabled}
/>
<Select
data-testid="fit-option-select"
label={t("convert.fitOption", "Fit Option")}
value={parameters.imageOptions.fitOption}
onChange={(val) => 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}
/>
<Switch
data-testid="auto-rotate-switch"
label={t("convert.autoRotate", "Auto Rotate")}
description={t("convert.autoRotateDescription", "Automatically rotate images to better fit the PDF page")}
checked={parameters.imageOptions.autoRotate}
onChange={(event) => onParameterChange('imageOptions', {
...parameters.imageOptions,
autoRotate: event.currentTarget.checked
})}
disabled={disabled}
/>
<Switch
data-testid="combine-images-switch"
label={t("convert.combineImages", "Combine Images")}
description={t("convert.combineImagesDescription", "Combine all images into one PDF, or create separate PDFs for each image")}
checked={parameters.imageOptions.combineImages}
onChange={(event) => onParameterChange('imageOptions', {
...parameters.imageOptions,
combineImages: event.currentTarget.checked
})}
disabled={disabled}
/>
</Stack>
);
};
export default ConvertFromImageSettings;

View File

@ -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 (
<Stack gap="sm" data-testid="web-settings">
<Text size="sm" fw={500}>{t("convert.webOptions", "Web to PDF Options")}:</Text>
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.zoomLevel", "Zoom Level")}:</Text>
<NumberInput
value={parameters.htmlOptions.zoomLevel}
onChange={(value) => 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"
/>
<Slider
value={parameters.htmlOptions.zoomLevel}
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: value
})}
min={0.1}
max={3.0}
step={0.1}
disabled={disabled}
data-testid="zoom-level-slider"
/>
</Stack>
</Stack>
);
};
export default ConvertFromWebSettings;

View File

@ -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<string>();
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-<extension> 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 (
<Stack gap="md">
{/* Format Selection */}
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("convert.convertFrom", "Convert from")}:
</Text>
<GroupedFormatDropdown
name="convert-from-dropdown"
data-testid="convert-from-dropdown"
value={parameters.fromExtension}
placeholder={t("convert.sourceFormatPlaceholder", "Source format")}
options={enhancedFromOptions}
onChange={handleFromExtensionChange}
disabled={disabled}
minWidth="18rem"
/>
</Stack>
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("convert.convertTo", "Convert to")}:
</Text>
{!parameters.fromExtension ? (
<UnstyledButton
style={{
padding: '0.5rem 0.75rem',
border: `0.0625rem solid ${theme.colors.gray[4]}`,
borderRadius: theme.radius.sm,
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
cursor: 'not-allowed'
}}
>
<Group justify="space-between">
<Text size="sm">{t("convert.selectSourceFormatFirst", "Select a source format first")}</Text>
<KeyboardArrowDownIcon
style={{
fontSize: '1rem',
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6]
}}
/>
</Group>
</UnstyledButton>
) : (
<GroupedFormatDropdown
name="convert-to-dropdown"
data-testid="convert-to-dropdown"
value={parameters.toExtension}
placeholder={t("convert.targetFormatPlaceholder", "Target format")}
options={enhancedToOptions}
onChange={handleToExtensionChange}
disabled={disabled}
minWidth="18rem"
/>
)}
</Stack>
{/* Format-specific options */}
{isImageFormat(parameters.toExtension) && (
<>
<Divider />
<ConvertToImageSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{/* Color options for image to PDF conversion */}
{(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
(parameters.isSmartDetection && parameters.smartDetectionType === 'images') ? (
<>
<Divider />
<ConvertFromImageSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
) : null}
{/* Web to PDF options */}
{((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
(parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? (
<>
<Divider />
<ConvertFromWebSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
) : null}
{/* Email to PDF options */}
{parameters.fromExtension === 'eml' && parameters.toExtension === 'pdf' && (
<>
<Divider />
<ConvertFromEmailSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{/* PDF to PDF/A options */}
{parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa' && (
<>
<Divider />
<ConvertToPdfaSettings
parameters={parameters}
onParameterChange={onParameterChange}
selectedFiles={selectedFiles}
disabled={disabled}
/>
</>
)}
</Stack>
);
};
export default ConvertSettings;

View File

@ -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 (
<Stack gap="sm" data-testid="image-options-section">
<Text size="sm" fw={500} data-testid="image-options-title">{t("convert.imageOptions", "Image Options")}:</Text>
<Group grow>
<Select
data-testid="color-type-select"
label={t("convert.colorType", "Color Type")}
value={parameters.imageOptions.colorType}
onChange={(val) => val && onParameterChange('imageOptions', {
...parameters.imageOptions,
colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES]
})}
data={[
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
]}
disabled={disabled}
/>
<NumberInput
data-testid="dpi-input"
label={t("convert.dpi", "DPI")}
value={parameters.imageOptions.dpi}
onChange={(val) => typeof val === 'number' && onParameterChange('imageOptions', {
...parameters.imageOptions,
dpi: val
})}
min={72}
max={600}
step={1}
disabled={disabled}
/>
</Group>
<Select
data-testid="output-type-select"
label={t("convert.output", "Output")}
value={parameters.imageOptions.singleOrMultiple}
onChange={(val) => 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}
/>
</Stack>
);
};
export default ConvertToImageSettings;

View File

@ -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 (
<Stack gap="sm" data-testid="pdfa-settings">
<Text size="sm" fw={500}>{t("convert.pdfaOptions", "PDF/A Options")}:</Text>
{hasDigitalSignatures && (
<Alert color="yellow" size="sm">
<Text size="sm">
{t("convert.pdfaDigitalSignatureWarning", "The PDF contains a digital signature. This will be removed in the next step.")}
</Text>
</Alert>
)}
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.outputFormat", "Output Format")}:</Text>
<Select
value={parameters.pdfaOptions.outputFormat}
onChange={(value) => onParameterChange('pdfaOptions', {
...parameters.pdfaOptions,
outputFormat: value || 'pdfa-1'
})}
data={pdfaFormatOptions}
disabled={disabled || isChecking}
data-testid="pdfa-output-format-select"
/>
<Text size="xs" c="dimmed">
{t("convert.pdfaNote", "PDF/A-1b is more compatible, PDF/A-2b supports more features.")}
</Text>
</Stack>
</Stack>
);
};
export default ConvertToPdfaSettings;

Some files were not shown because too many files have changed in this diff Show More