Compare commits

...
15 Commits
Author SHA1 Message Date
Anthony Stirling a187f0599b camers scan init 2025-06-04 15:45:50 +01:00
Anthony Stirling 488fe253aa test 2025-06-03 13:41:24 +01:00
Anthony Stirling 7874d18f32 fix 2025-06-03 13:19:42 +01:00
a 1e968c79f6 Merge branch 'websocketSupportFun' of git@github.com:Stirling-Tools/Stirling-PDF.git into websocketSupportFun 2025-06-03 13:15:02 +01:00
Anthony Stirling e2b88cf8b4 fix tests 2025-06-03 13:08:32 +01:00
Anthony Stirling da49b50d27 Update build.yml 2025-06-03 13:04:02 +01:00
Anthony Stirling 970679640d Merge branch 'main' into websocketSupportFun 2025-06-03 13:01:35 +01:00
Anthony Stirling 18f571291b extra result 2025-06-03 12:40:33 +01:00
Anthony Stirling 0abcc0ccb8 test fix 2025-06-03 12:36:00 +01:00
Anthony Stirling a99c104053 jdk17 support 2025-06-03 12:01:28 +01:00
Anthony Stirling 95fbd4647d log changing 2025-06-03 10:34:47 +01:00
Anthony Stirling 8d4f5f16cb Queue, timeouts, retries, tests! 2025-06-03 01:18:07 +01:00
Anthony Stirling 2ee43b2070 update all endpoints 2025-06-02 22:49:46 +01:00
Anthony Stirling 02aad40aa0 Tests cleanup and stuff 2025-06-02 11:06:20 +01:00
Anthony Stirling 0328333d81 websocket fun! 2025-06-02 01:19:54 +01:00
115 changed files with 6629 additions and 148 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(./gradlew:*)",
"Bash(grep:*)",
"Bash(find:*)",
"Bash(chmod:*)",
"Bash(rm:*)",
"Bash(cat:*)",
"Bash(if [ -d logs ])",
"Bash(then ls -la logs/)",
"Bash(fi)",
"Bash(ls:*)",
"Bash(./testing/cucumber/features/environment.py -v)",
"Bash(./testing/cucumber/features/autojob.feature -v)",
"Bash(git rev-parse:*)"
],
"deny": []
}
}
+3 -3
View File
@@ -56,9 +56,9 @@ jobs:
build/reports/tests/
build/test-results/
build/reports/problems/
/common/build/reports/tests/
/common/build/test-results/
/common/build/reports/problems/
common/build/reports/tests/
common/build/test-results/
common/build/reports/problems/
retention-days: 3
check-licence:
+1
View File
@@ -478,6 +478,7 @@ dependencies {
implementation 'org.snakeyaml:snakeyaml-engine:2.9'
testImplementation "org.springframework.boot:spring-boot-starter-test:$springBootVersion"
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// Batik
implementation "org.apache.xmlgraphics:batik-all:1.19"
+13
View File
@@ -32,6 +32,9 @@ dependencyManagement {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
implementation 'com.fathzer:javaluator:3.0.6'
implementation 'com.posthog.java:posthog:1.2.0'
@@ -48,5 +51,15 @@ dependencies {
annotationProcessor "org.projectlombok:lombok:$lombokVersion"
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation 'org.mockito:mockito-core:5.18.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.18.0'
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
}
}
@@ -0,0 +1,47 @@
package stirling.software.common.annotations;
import java.lang.annotation.*;
import org.springframework.core.annotation.AliasFor;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.POST)
public @interface AutoJobPostMapping {
@AliasFor(annotation = RequestMapping.class, attribute = "value")
String[] value() default {};
@AliasFor(annotation = RequestMapping.class, attribute = "consumes")
String[] consumes() default {"multipart/form-data"};
/**
* Custom timeout in milliseconds for this specific job. If not specified, the default system
* timeout will be used.
*/
long timeout() default -1;
/** Maximum number of times to retry the job on failure. Default is 1 (no retries). */
int retryCount() default 1;
/**
* Whether to track and report progress for this job. If enabled, the job will send progress
* updates through WebSocket.
*/
boolean trackProgress() default true;
/**
* Whether this job can be queued when system resources are limited. If enabled, jobs will be
* queued instead of rejected when the system is under high load. The queue size is dynamically
* adjusted based on available memory and CPU resources.
*/
boolean queueable() default false;
/**
* Optional resource weight of this job (1-100). Higher values indicate more resource-intensive
* jobs that may need stricter queuing. Default is 50 (medium weight).
*/
int resourceWeight() default 50;
}
@@ -0,0 +1,249 @@
package stirling.software.common.aop;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.controller.WebSocketProgressController;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.model.job.JobProgress;
import stirling.software.common.service.FileOrUploadService;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class AutoJobAspect {
private final JobExecutorService jobExecutorService;
private final HttpServletRequest request;
private final FileOrUploadService fileOrUploadService;
private final FileStorage fileStorage;
private final WebSocketProgressController webSocketSender;
@Around("@annotation(autoJobPostMapping)")
public Object wrapWithJobExecution(
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
// Extract parameters from the request and annotation
boolean async = Boolean.parseBoolean(request.getParameter("async"));
long timeout = autoJobPostMapping.timeout();
int retryCount = autoJobPostMapping.retryCount();
boolean trackProgress = autoJobPostMapping.trackProgress();
log.debug(
"AutoJobPostMapping execution with async={}, timeout={}, retryCount={}, trackProgress={}",
async,
timeout > 0 ? timeout : "default",
retryCount,
trackProgress);
// Inspect and possibly mutate arguments
Object[] args = joinPoint.getArgs();
for (int i = 0; i < args.length; i++) {
Object arg = args[i];
if (arg instanceof PDFFile pdfFile) {
// Case 1: fileId is provided but no fileInput
if (pdfFile.getFileInput() == null && pdfFile.getFileId() != null) {
try {
log.debug("Using fileId {} to get file content", pdfFile.getFileId());
MultipartFile file = fileStorage.retrieveFile(pdfFile.getFileId());
pdfFile.setFileInput(file);
} catch (Exception e) {
throw new RuntimeException(
"Failed to resolve file by ID: " + pdfFile.getFileId(), e);
}
}
// Case 2: For async requests, we need to make a copy of the MultipartFile
else if (async && pdfFile.getFileInput() != null) {
try {
log.debug("Making persistent copy of uploaded file for async processing");
MultipartFile originalFile = pdfFile.getFileInput();
String fileId = fileStorage.storeFile(originalFile);
// Store the fileId for later reference
pdfFile.setFileId(fileId);
// Replace the original MultipartFile with our persistent copy
MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
pdfFile.setFileInput(persistentFile);
log.debug("Created persistent file copy with fileId: {}", fileId);
} catch (IOException e) {
throw new RuntimeException(
"Failed to create persistent copy of uploaded file", e);
}
}
}
}
// Extract queueable and resourceWeight parameters
boolean queueable = autoJobPostMapping.queueable();
int resourceWeight = autoJobPostMapping.resourceWeight();
// Integrate with the enhanced JobExecutorService
if (retryCount <= 1) {
// No retries needed, simple execution
return jobExecutorService.runJobGeneric(
async,
() -> {
try {
if (trackProgress && async) {
String jobId = (String) request.getAttribute("jobId");
if (jobId != null) {
webSocketSender.sendProgress(
jobId,
new JobProgress(
jobId,
"Processing",
50,
"Executing operation"));
}
}
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
throw new RuntimeException(ex);
}
},
timeout,
queueable,
resourceWeight);
} else {
// Use retry logic
return executeWithRetries(
joinPoint,
args,
async,
timeout,
retryCount,
trackProgress,
queueable,
resourceWeight);
}
}
private Object executeWithRetries(
ProceedingJoinPoint joinPoint,
Object[] args,
boolean async,
long timeout,
int maxRetries,
boolean trackProgress,
boolean queueable,
int resourceWeight) {
AtomicInteger attempts = new AtomicInteger(0);
// Use AtomicReference to make the jobId effectively final for lambda usage
AtomicReference<String> jobIdRef = new AtomicReference<>();
return jobExecutorService.runJobGeneric(
async,
() -> {
int currentAttempt = attempts.incrementAndGet();
try {
if (trackProgress && async) {
// Only get the jobId once and store it in the AtomicReference
if (jobIdRef.get() == null) {
jobIdRef.set(getJobIdFromContext());
}
String jobId = jobIdRef.get();
if (jobId != null) {
webSocketSender.sendProgress(
jobId,
new JobProgress(
jobId,
"Started",
0,
"Executing (attempt "
+ currentAttempt
+ " of "
+ Math.max(1, maxRetries)
+ ")"));
}
}
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution (attempt {}/{}): {}",
currentAttempt,
Math.max(1, maxRetries),
ex.getMessage(),
ex);
// Check if we should retry
if (currentAttempt < maxRetries) {
log.info(
"Retrying operation, attempt {}/{}",
currentAttempt + 1,
maxRetries);
String jobId = jobIdRef.get();
if (trackProgress && async && jobId != null) {
webSocketSender.sendProgress(
jobId,
new JobProgress(
jobId,
"Retrying",
(int) (currentAttempt * 100.0 / maxRetries),
"Retry attempt "
+ (currentAttempt + 1)
+ " of "
+ maxRetries));
}
try {
// Simple exponential backoff
Thread.sleep(100 * currentAttempt);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
// Recursive call to retry
return executeWithRetries(
joinPoint,
args,
async,
timeout,
maxRetries,
trackProgress,
queueable,
resourceWeight);
}
// No more retries, throw the exception
throw new RuntimeException("Job failed: " + ex.getMessage(), ex);
}
},
timeout,
queueable,
resourceWeight);
}
// Try to get the job ID from the context (if this is an async job)
private String getJobIdFromContext() {
try {
return (String) request.getAttribute("jobId");
} catch (Exception e) {
log.debug("Could not retrieve job ID from context: {}", e.getMessage());
return null;
}
}
}
@@ -0,0 +1,23 @@
package stirling.software.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
}
}
@@ -0,0 +1,114 @@
package stirling.software.common.controller;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.TaskManager;
/** REST controller for job-related endpoints */
@RestController
@RequiredArgsConstructor
@Slf4j
public class JobController {
private final TaskManager taskManager;
private final FileStorage fileStorage;
/**
* Get the status of a job
*
* @param jobId The job ID
* @return The job result
*/
@GetMapping("/api/v1/general/job/{jobId}")
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(result);
}
/**
* Get the result of a job
*
* @param jobId The job ID
* @return The job result
*/
@GetMapping("/api/v1/general/job/{jobId}/result")
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
}
if (!result.isComplete()) {
return ResponseEntity.badRequest().body("Job is not complete yet");
}
if (result.getError() != null) {
return ResponseEntity.badRequest().body("Job failed: " + result.getError());
}
if (result.getFileId() != null) {
try {
byte[] fileContent = fileStorage.retrieveBytes(result.getFileId());
return ResponseEntity.ok()
.header("Content-Type", result.getContentType())
.header(
"Content-Disposition",
"form-data; name=\"attachment\"; filename=\""
+ result.getOriginalFileName()
+ "\"")
.body(fileContent);
} catch (Exception e) {
log.error("Error retrieving file for job {}: {}", jobId, e.getMessage(), e);
return ResponseEntity.internalServerError()
.body("Error retrieving file: " + e.getMessage());
}
}
return ResponseEntity.ok(result.getResult());
}
/**
* Get statistics about jobs in the system
*
* @return Job statistics
*/
@GetMapping("/api/v1/general/job/stats")
public ResponseEntity<JobStats> getJobStats() {
JobStats stats = taskManager.getJobStats();
return ResponseEntity.ok(stats);
}
/**
* Manually trigger cleanup of old jobs
*
* @return A response indicating how many jobs were cleaned up
*/
@PostMapping("/api/v1/general/job/cleanup")
public ResponseEntity<?> cleanupOldJobs() {
int beforeCount = taskManager.getJobStats().getTotalJobs();
taskManager.cleanupOldJobs();
int afterCount = taskManager.getJobStats().getTotalJobs();
int removedCount = beforeCount - afterCount;
return ResponseEntity.ok(
Map.of(
"message", "Cleanup complete",
"removedJobs", removedCount,
"remainingJobs", afterCount));
}
}
@@ -0,0 +1,27 @@
package stirling.software.common.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import lombok.NoArgsConstructor;
import stirling.software.common.model.job.JobProgress;
@Controller
@NoArgsConstructor
public class WebSocketProgressController {
private SimpMessagingTemplate messagingTemplate;
@Autowired(required = false)
public void setMessagingTemplate(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public void sendProgress(String jobId, JobProgress progress) {
if (messagingTemplate != null) {
messagingTemplate.convertAndSend("/topic/progress/" + jobId, progress);
}
}
}
@@ -14,8 +14,12 @@ import lombok.NoArgsConstructor;
public class PDFFile {
@Schema(
description = "The input PDF file",
requiredMode = Schema.RequiredMode.REQUIRED,
contentMediaType = "application/pdf",
format = "binary")
private MultipartFile fileInput;
@Schema(
description = "File ID for server-side files (can be used instead of fileInput)",
example = "a1b2c3d4-5678-90ab-cdef-ghijklmnopqr")
private String fileId;
}
@@ -0,0 +1,15 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobProgress {
private String jobId;
private String status;
private int percentComplete;
private String message;
}
@@ -0,0 +1,14 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobResponse<T> {
private boolean async;
private String jobId;
private T result;
}
@@ -0,0 +1,94 @@
package stirling.software.common.model.job;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents the result of a job execution. Used by the TaskManager to store job results. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobResult {
/** The job ID */
private String jobId;
/** Flag indicating if the job is complete */
private boolean complete;
/** Error message if the job failed */
private String error;
/** The file ID of the result file, if applicable */
private String fileId;
/** Original file name, if applicable */
private String originalFileName;
/** MIME type of the result, if applicable */
private String contentType;
/** Time when the job was created */
private LocalDateTime createdAt;
/** Time when the job was completed */
private LocalDateTime completedAt;
/** The actual result object, if not a file */
private Object result;
/**
* Create a new JobResult with the given job ID
*
* @param jobId The job ID
* @return A new JobResult
*/
public static JobResult createNew(String jobId) {
return JobResult.builder()
.jobId(jobId)
.complete(false)
.createdAt(LocalDateTime.now())
.build();
}
/**
* Mark this job as complete with a file result
*
* @param fileId The file ID of the result
* @param originalFileName The original file name
* @param contentType The content type of the file
*/
public void completeWithFile(String fileId, String originalFileName, String contentType) {
this.complete = true;
this.fileId = fileId;
this.originalFileName = originalFileName;
this.contentType = contentType;
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as complete with a general result
*
* @param result The result object
*/
public void completeWithResult(Object result) {
this.complete = true;
this.result = result;
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as failed with an error message
*
* @param error The error message
*/
public void failWithError(String error) {
this.complete = true;
this.error = error;
this.completedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,43 @@
package stirling.software.common.model.job;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents statistics about jobs in the system */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobStats {
/** Total number of jobs (active and completed) */
private int totalJobs;
/** Number of active (incomplete) jobs */
private int activeJobs;
/** Number of completed jobs */
private int completedJobs;
/** Number of failed jobs */
private int failedJobs;
/** Number of successful jobs */
private int successfulJobs;
/** Number of jobs with file results */
private int fileResultJobs;
/** The oldest active job's creation timestamp */
private LocalDateTime oldestActiveJobTime;
/** The newest active job's creation timestamp */
private LocalDateTime newestActiveJobTime;
/** The average processing time for completed jobs in milliseconds */
private long averageProcessingTimeMs;
}
@@ -0,0 +1,78 @@
package stirling.software.common.service;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class FileOrUploadService {
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
public Path resolveFilePath(String fileId) {
return Path.of(tempDirPath).resolve(fileId);
}
public MultipartFile toMockMultipartFile(String name, byte[] data) throws IOException {
return new CustomMultipartFile(name, data);
}
// Custom implementation of MultipartFile
private static class CustomMultipartFile implements MultipartFile {
private final String name;
private final byte[] content;
public CustomMultipartFile(String name, byte[] content) {
this.name = name;
this.content = content;
}
@Override
public String getName() {
return name;
}
@Override
public String getOriginalFilename() {
return name;
}
@Override
public String getContentType() {
return "application/pdf";
}
@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}
@Override
public long getSize() {
return content.length;
}
@Override
public byte[] getBytes() throws IOException {
return content;
}
@Override
public java.io.InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(content);
}
@Override
public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
Files.write(dest.toPath(), content);
}
}
}
@@ -0,0 +1,152 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
* system to handle file references.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FileStorage {
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
private final FileOrUploadService fileOrUploadService;
/**
* Store a file and return its unique ID
*
* @param file The file to store
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeFile(MultipartFile file) throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Transfer the file to the storage location
file.transferTo(filePath.toFile());
log.debug("Stored file with ID: {}", fileId);
return fileId;
}
/**
* Store a byte array as a file and return its unique ID
*
* @param bytes The byte array to store
* @param originalName The original name of the file (for extension)
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeBytes(byte[] bytes, String originalName) throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Write the bytes to the file
Files.write(filePath, bytes);
log.debug("Stored byte array with ID: {}", fileId);
return fileId;
}
/**
* Retrieve a file by its ID as a MultipartFile
*
* @param fileId The ID of the file to retrieve
* @return The file as a MultipartFile
* @throws IOException If the file doesn't exist or can't be read
*/
public MultipartFile retrieveFile(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
byte[] fileData = Files.readAllBytes(filePath);
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
}
/**
* Retrieve a file by its ID as a byte array
*
* @param fileId The ID of the file to retrieve
* @return The file as a byte array
* @throws IOException If the file doesn't exist or can't be read
*/
public byte[] retrieveBytes(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.readAllBytes(filePath);
}
/**
* Delete a file by its ID
*
* @param fileId The ID of the file to delete
* @return true if the file was deleted, false otherwise
*/
public boolean deleteFile(String fileId) {
try {
Path filePath = getFilePath(fileId);
return Files.deleteIfExists(filePath);
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
}
/**
* Check if a file exists by its ID
*
* @param fileId The ID of the file to check
* @return true if the file exists, false otherwise
*/
public boolean fileExists(String fileId) {
Path filePath = getFilePath(fileId);
return Files.exists(filePath);
}
/**
* Get the path for a file ID
*
* @param fileId The ID of the file
* @return The path to the file
*/
private Path getFilePath(String fileId) {
return Path.of(tempDirPath).resolve(fileId);
}
/**
* Generate a unique file ID
*
* @return A unique file ID
*/
private String generateFileId() {
return UUID.randomUUID().toString();
}
}
@@ -0,0 +1,465 @@
package stirling.software.common.service;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.controller.WebSocketProgressController;
import stirling.software.common.model.job.JobProgress;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.util.ExecutorFactory;
/** Service for executing jobs asynchronously or synchronously */
@Service
@Slf4j
public class JobExecutorService {
private final TaskManager taskManager;
private final WebSocketProgressController webSocketSender;
private final FileStorage fileStorage;
private final HttpServletRequest request;
private final ResourceMonitor resourceMonitor;
private final JobQueue jobQueue;
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final long effectiveTimeoutMs;
public JobExecutorService(
TaskManager taskManager,
WebSocketProgressController webSocketSender,
FileStorage fileStorage,
HttpServletRequest request,
ResourceMonitor resourceMonitor,
JobQueue jobQueue,
@Value("${spring.mvc.async.request-timeout:1200000}") long asyncRequestTimeoutMs,
@Value("${server.servlet.session.timeout:30m}") String sessionTimeout) {
this.taskManager = taskManager;
this.webSocketSender = webSocketSender;
this.fileStorage = fileStorage;
this.request = request;
this.resourceMonitor = resourceMonitor;
this.jobQueue = jobQueue;
// Parse session timeout and calculate effective timeout once during initialization
long sessionTimeoutMs = parseSessionTimeout(sessionTimeout);
this.effectiveTimeoutMs = Math.min(asyncRequestTimeoutMs, sessionTimeoutMs);
log.debug(
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
}
/**
* Run a job either asynchronously or synchronously
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @return The response
*/
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
}
/**
* Run a job either asynchronously or synchronously with a custom timeout
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
* @return The response
*/
public ResponseEntity<?> runJobGeneric(
boolean async, Supplier<Object> work, long customTimeoutMs) {
return runJobGeneric(async, work, customTimeoutMs, false, 50);
}
/**
* Run a job either asynchronously or synchronously with custom parameters
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
* @param queueable Whether this job can be queued when system resources are limited
* @param resourceWeight The resource weight of this job (1-100)
* @return The response
*/
public ResponseEntity<?> runJobGeneric(
boolean async,
Supplier<Object> work,
long customTimeoutMs,
boolean queueable,
int resourceWeight) {
String jobId = UUID.randomUUID().toString();
// Store the job ID in the request for potential use by other components
if (request != null) {
request.setAttribute("jobId", jobId);
}
// Determine which timeout to use
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
log.debug(
"Running job with ID: {}, async: {}, timeout: {}ms, queueable: {}, weight: {}",
jobId,
async,
timeoutToUse,
queueable,
resourceWeight);
// Check if we need to queue this job based on resource availability
boolean shouldQueue =
queueable
&& async
&& // Only async jobs can be queued
resourceMonitor.shouldQueueJob(resourceWeight);
if (shouldQueue) {
// Queue the job instead of executing immediately
log.debug(
"Queueing job {} due to resource constraints (weight: {})",
jobId,
resourceWeight);
taskManager.createTask(jobId);
// Create a specialized wrapper that updates the TaskManager
Supplier<Object> wrappedWork =
() -> {
try {
Object result = work.get();
processJobResult(jobId, result);
return result;
} catch (Exception e) {
log.error(
"Error executing queued job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
throw e;
}
};
// Queue the job and get the future
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob(jobId, resourceWeight, wrappedWork, timeoutToUse);
// Return immediately with job ID
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) {
taskManager.createTask(jobId);
webSocketSender.sendProgress(jobId, new JobProgress(jobId, "Started", 0, "Running"));
executor.execute(
() -> {
try {
log.debug(
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(jobId, result);
webSocketSender.sendProgress(
jobId, new JobProgress(jobId, "Done", 100, "Complete"));
} catch (TimeoutException te) {
log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
taskManager.setError(jobId, "Job timed out");
webSocketSender.sendProgress(
jobId, new JobProgress(jobId, "Error", 100, "Job timed out"));
} catch (Exception e) {
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
webSocketSender.sendProgress(
jobId, new JobProgress(jobId, "Error", 100, e.getMessage()));
}
});
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else {
try {
log.debug("Running sync job with timeout {} ms", timeoutToUse);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
// If the result is already a ResponseEntity, return it directly
if (result instanceof ResponseEntity) {
return (ResponseEntity<?>) result;
}
// Process different result types
return handleResultForSyncJob(result);
} catch (TimeoutException te) {
log.error("Synchronous job timed out after {} ms", timeoutToUse);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
} catch (Exception e) {
log.error("Error executing synchronous job: {}", e.getMessage(), e);
// Construct a JSON error response
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
}
}
}
/**
* Process the result of an asynchronous job
*
* @param jobId The job ID
* @param result The result
*/
private void processJobResult(String jobId, Object result) {
try {
if (result instanceof byte[]) {
// Store byte array as a file
String fileId = fileStorage.storeBytes((byte[]) result, "result.pdf");
taskManager.setFileResult(jobId, fileId, "result.pdf", "application/pdf");
log.debug("Stored byte[] result with fileId: {}", fileId);
} else if (result instanceof ResponseEntity) {
ResponseEntity<?> response = (ResponseEntity<?>) result;
Object body = response.getBody();
if (body instanceof byte[]) {
// Extract filename from content-disposition header if available
String filename = "result.pdf";
String contentType = "application/pdf";
if (response.getHeaders().getContentDisposition() != null) {
String disposition =
response.getHeaders().getContentDisposition().toString();
if (disposition.contains("filename=")) {
filename =
disposition.substring(
disposition.indexOf("filename=") + 9,
disposition.lastIndexOf("\""));
}
}
if (response.getHeaders().getContentType() != null) {
contentType = response.getHeaders().getContentType().toString();
}
String fileId = fileStorage.storeBytes((byte[]) body, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Stored ResponseEntity<byte[]> result with fileId: {}", fileId);
} else {
// Check if the response body contains a fileId
if (body != null && body.toString().contains("fileId")) {
try {
// Try to extract fileId using reflection
java.lang.reflect.Method getFileId =
body.getClass().getMethod("getFileId");
String fileId = (String) getFileId.invoke(body);
if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type
String filename = "result.pdf";
String contentType = "application/pdf";
try {
java.lang.reflect.Method getOriginalFileName =
body.getClass().getMethod("getOriginalFilename");
String origName = (String) getOriginalFileName.invoke(body);
if (origName != null && !origName.isEmpty()) {
filename = origName;
}
} catch (Exception e) {
log.debug(
"Could not get original filename: {}", e.getMessage());
}
try {
java.lang.reflect.Method getContentType =
body.getClass().getMethod("getContentType");
String ct = (String) getContentType.invoke(body);
if (ct != null && !ct.isEmpty()) {
contentType = ct;
}
} catch (Exception e) {
log.debug("Could not get content type: {}", e.getMessage());
}
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Extracted fileId from response body: {}", fileId);
taskManager.setComplete(jobId);
return;
}
} catch (Exception e) {
log.debug(
"Failed to extract fileId from response body: {}",
e.getMessage());
}
}
// Store generic result
taskManager.setResult(jobId, body);
}
} else if (result instanceof MultipartFile) {
MultipartFile file = (MultipartFile) result;
String fileId = fileStorage.storeFile(file);
taskManager.setFileResult(
jobId, fileId, file.getOriginalFilename(), file.getContentType());
log.debug("Stored MultipartFile result with fileId: {}", fileId);
} else {
// Check if result has a fileId field
if (result != null) {
try {
// Try to extract fileId using reflection
java.lang.reflect.Method getFileId =
result.getClass().getMethod("getFileId");
String fileId = (String) getFileId.invoke(result);
if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type
String filename = "result.pdf";
String contentType = "application/pdf";
try {
java.lang.reflect.Method getOriginalFileName =
result.getClass().getMethod("getOriginalFilename");
String origName = (String) getOriginalFileName.invoke(result);
if (origName != null && !origName.isEmpty()) {
filename = origName;
}
} catch (Exception e) {
log.debug("Could not get original filename: {}", e.getMessage());
}
try {
java.lang.reflect.Method getContentType =
result.getClass().getMethod("getContentType");
String ct = (String) getContentType.invoke(result);
if (ct != null && !ct.isEmpty()) {
contentType = ct;
}
} catch (Exception e) {
log.debug("Could not get content type: {}", e.getMessage());
}
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Extracted fileId from result object: {}", fileId);
taskManager.setComplete(jobId);
return;
}
} catch (Exception e) {
log.debug(
"Failed to extract fileId from result object: {}", e.getMessage());
}
}
// Default case: store the result as is
taskManager.setResult(jobId, result);
}
taskManager.setComplete(jobId);
} catch (Exception e) {
log.error("Error processing job result: {}", e.getMessage(), e);
taskManager.setError(jobId, "Error processing result: " + e.getMessage());
}
}
/**
* Handle different result types for synchronous jobs
*
* @param result The result object
* @return The appropriate ResponseEntity
* @throws IOException If there is an error processing the result
*/
private ResponseEntity<?> handleResultForSyncJob(Object result) throws IOException {
if (result instanceof byte[]) {
// Return byte array as PDF
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\"result.pdf\"")
.body(result);
} else if (result instanceof MultipartFile) {
// Return MultipartFile content
MultipartFile file = (MultipartFile) result;
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.getContentType()))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\""
+ file.getOriginalFilename()
+ "\"")
.body(file.getBytes());
} else {
// Default case: return as JSON
return ResponseEntity.ok(result);
}
}
/**
* Parse session timeout string (e.g., "30m", "1h") to milliseconds
*
* @param timeout The timeout string
* @return The timeout in milliseconds
*/
private long parseSessionTimeout(String timeout) {
if (timeout == null || timeout.isEmpty()) {
return 30 * 60 * 1000; // Default: 30 minutes
}
try {
String value = timeout.replaceAll("[^\\d.]", "");
String unit = timeout.replaceAll("[\\d.]", "");
double numericValue = Double.parseDouble(value);
return switch (unit.toLowerCase()) {
case "s" -> (long) (numericValue * 1000);
case "m" -> (long) (numericValue * 60 * 1000);
case "h" -> (long) (numericValue * 60 * 60 * 1000);
case "d" -> (long) (numericValue * 24 * 60 * 60 * 1000);
default -> (long) (numericValue * 60 * 1000); // Default to minutes
};
} catch (Exception e) {
log.warn("Could not parse session timeout '{}', using default", timeout);
return 30 * 60 * 1000; // Default: 30 minutes
}
}
/**
* Execute a supplier with a timeout
*
* @param supplier The supplier to execute
* @param timeoutMs The timeout in milliseconds
* @return The result from the supplier
* @throws TimeoutException If the execution times out
* @throws Exception If the supplier throws an exception
*/
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
throws TimeoutException, Exception {
java.util.concurrent.CompletableFuture<T> future =
java.util.concurrent.CompletableFuture.supplyAsync(supplier);
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (java.util.concurrent.TimeoutException e) {
future.cancel(true);
throw new TimeoutException("Execution timed out after " + timeoutMs + " ms");
} catch (java.util.concurrent.ExecutionException e) {
throw (Exception) e.getCause();
} catch (java.util.concurrent.CancellationException e) {
throw new Exception("Execution was cancelled", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new Exception("Execution was interrupted", e);
}
}
}
@@ -0,0 +1,428 @@
package stirling.software.common.service;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.*;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.controller.WebSocketProgressController;
import stirling.software.common.model.job.JobProgress;
import stirling.software.common.util.ExecutorFactory;
/**
* Manages a queue of jobs with dynamic sizing based on system resources. Used when system resources
* are limited to prevent overloading.
*/
@Service
@Slf4j
public class JobQueue {
private final ResourceMonitor resourceMonitor;
private final WebSocketProgressController webSocketSender;
@Value("${stirling.job.queue.base-capacity:10}")
private int baseQueueCapacity = 10;
@Value("${stirling.job.queue.min-capacity:2}")
private int minQueueCapacity = 2;
@Value("${stirling.job.queue.check-interval-ms:1000}")
private long queueCheckIntervalMs = 1000;
@Value("${stirling.job.queue.max-wait-time-ms:600000}")
private long maxWaitTimeMs = 600000; // 10 minutes
private BlockingQueue<QueuedJob> jobQueue;
private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private boolean shuttingDown = false;
@Getter private int rejectedJobs = 0;
@Getter private int totalQueuedJobs = 0;
@Getter private int currentQueueSize = 0;
/** Represents a job waiting in the queue. */
@Data
@AllArgsConstructor
private static class QueuedJob {
private final String jobId;
private final int resourceWeight;
private final Supplier<Object> work;
private final long timeoutMs;
private final Instant queuedAt;
private CompletableFuture<ResponseEntity<?>> future;
private volatile boolean cancelled = false;
}
public JobQueue(ResourceMonitor resourceMonitor, WebSocketProgressController webSocketSender) {
this.resourceMonitor = resourceMonitor;
this.webSocketSender = webSocketSender;
// Initialize with dynamic capacity
int capacity =
resourceMonitor.calculateDynamicQueueCapacity(baseQueueCapacity, minQueueCapacity);
this.jobQueue = new LinkedBlockingQueue<>(capacity);
}
@PostConstruct
public void initialize() {
log.debug(
"Starting job queue with base capacity {}, min capacity {}",
baseQueueCapacity,
minQueueCapacity);
// Periodically process the job queue
scheduler.scheduleWithFixedDelay(
this::processQueue, 0, queueCheckIntervalMs, TimeUnit.MILLISECONDS);
// Periodically update queue capacity based on resource usage
scheduler.scheduleWithFixedDelay(
this::updateQueueCapacity,
10000, // Initial delay
30000, // 30 second interval
TimeUnit.MILLISECONDS);
}
@PreDestroy
public void shutdown() {
log.info("Shutting down job queue");
shuttingDown = true;
// Complete any futures that are still waiting
jobMap.forEach(
(id, job) -> {
if (!job.future.isDone()) {
job.future.completeExceptionally(
new RuntimeException("Server shutting down, job cancelled"));
}
});
scheduler.shutdownNow();
jobExecutor.shutdownNow();
log.info(
"Job queue shutdown complete. Stats: total={}, rejected={}",
totalQueuedJobs,
rejectedJobs);
}
/**
* Queues a job for execution when resources permit.
*
* @param jobId The job ID
* @param resourceWeight The resource weight of the job (1-100)
* @param work The work to be done
* @param timeoutMs The timeout in milliseconds
* @return A CompletableFuture that will complete when the job is executed
*/
public CompletableFuture<ResponseEntity<?>> queueJob(
String jobId, int resourceWeight, Supplier<Object> work, long timeoutMs) {
// Create a CompletableFuture to track this job's completion
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
// Create the queued job
QueuedJob job =
new QueuedJob(jobId, resourceWeight, work, timeoutMs, Instant.now(), future, false);
// Store in our map for lookup
jobMap.put(jobId, job);
// Update stats
totalQueuedJobs++;
currentQueueSize = jobQueue.size();
// Try to add to the queue
try {
boolean added = jobQueue.offer(job, 5, TimeUnit.SECONDS);
if (!added) {
log.warn("Queue full, rejecting job {}", jobId);
rejectedJobs++;
future.completeExceptionally(
new RuntimeException("Job queue full, please try again later"));
jobMap.remove(jobId);
return future;
}
log.debug(
"Job {} queued for execution (weight: {}, queue size: {})",
jobId,
resourceWeight,
jobQueue.size());
// Notify client via WebSocket that job is queued
webSocketSender.sendProgress(
jobId, new JobProgress(jobId, "Queued", 0, "Waiting in queue for resources"));
return future;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
future.completeExceptionally(new RuntimeException("Job queue interrupted"));
jobMap.remove(jobId);
return future;
}
}
/**
* Gets the current capacity of the job queue.
*
* @return The current capacity
*/
public int getQueueCapacity() {
return ((LinkedBlockingQueue<QueuedJob>) jobQueue).remainingCapacity() + jobQueue.size();
}
/** Updates the capacity of the job queue based on available system resources. */
private void updateQueueCapacity() {
try {
// Calculate new capacity
int newCapacity =
resourceMonitor.calculateDynamicQueueCapacity(
baseQueueCapacity, minQueueCapacity);
int currentCapacity = getQueueCapacity();
if (newCapacity != currentCapacity) {
log.debug(
"Updating job queue capacity from {} to {}", currentCapacity, newCapacity);
// Create new queue with updated capacity
BlockingQueue<QueuedJob> newQueue = new LinkedBlockingQueue<>(newCapacity);
// Transfer jobs from old queue to new queue
jobQueue.drainTo(newQueue);
jobQueue = newQueue;
currentQueueSize = jobQueue.size();
}
} catch (Exception e) {
log.error("Error updating queue capacity: {}", e.getMessage(), e);
}
}
/** Processes jobs in the queue, executing them when resources permit. */
private void processQueue() {
if (shuttingDown || jobQueue.isEmpty()) {
return;
}
try {
// Get current resource status
ResourceMonitor.ResourceStatus status = resourceMonitor.getCurrentStatus().get();
// Check if we should execute any jobs
boolean canExecuteJobs = (status != ResourceMonitor.ResourceStatus.CRITICAL);
if (!canExecuteJobs) {
// Under critical load, don't execute any jobs
log.debug("System under critical load, delaying job execution");
return;
}
// Get jobs from the queue, up to a limit based on resource availability
int jobsToProcess =
Math.max(
1,
switch (status) {
case OK -> 3;
case WARNING -> 1;
case CRITICAL -> 0;
});
for (int i = 0; i < jobsToProcess && !jobQueue.isEmpty(); i++) {
QueuedJob job = jobQueue.poll();
if (job == null) break;
// Check if it's been waiting too long
long waitTimeMs = Instant.now().toEpochMilli() - job.queuedAt.toEpochMilli();
if (waitTimeMs > maxWaitTimeMs) {
log.warn(
"Job {} exceeded maximum wait time ({} ms), executing anyway",
job.jobId,
waitTimeMs);
}
// Remove from our map
jobMap.remove(job.jobId);
currentQueueSize = jobQueue.size();
// Notify via WebSocket
webSocketSender.sendProgress(
job.jobId,
new JobProgress(job.jobId, "Processing", 10, "Starting job execution"));
// Execute the job
executeJob(job);
}
} catch (Exception e) {
log.error("Error processing job queue: {}", e.getMessage(), e);
}
}
/**
* Executes a job from the queue.
*
* @param job The job to execute
*/
private void executeJob(QueuedJob job) {
if (job.cancelled) {
log.debug("Job {} was cancelled, not executing", job.jobId);
return;
}
jobExecutor.execute(
() -> {
log.debug("Executing queued job {} (queued at {})", job.jobId, job.queuedAt);
try {
// Execute with timeout
Object result = executeWithTimeout(job.work, job.timeoutMs);
// Process the result
if (result instanceof ResponseEntity) {
job.future.complete((ResponseEntity<?>) result);
} else {
job.future.complete(ResponseEntity.ok(result));
}
// Update WebSocket
webSocketSender.sendProgress(
job.jobId,
new JobProgress(
job.jobId, "Complete", 100, "Job completed successfully"));
} catch (Exception e) {
log.error(
"Error executing queued job {}: {}", job.jobId, e.getMessage(), e);
job.future.completeExceptionally(e);
// Update WebSocket
webSocketSender.sendProgress(
job.jobId,
new JobProgress(
job.jobId, "Error", 100, "Job failed: " + e.getMessage()));
}
});
}
/**
* Execute a supplier with a timeout.
*
* @param supplier The supplier to execute
* @param timeoutMs The timeout in milliseconds
* @return The result from the supplier
* @throws Exception If there is an execution error
*/
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs) throws Exception {
CompletableFuture<T> future = CompletableFuture.supplyAsync(supplier);
try {
if (timeoutMs <= 0) {
// No timeout
return future.join();
} else {
// With timeout
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
}
} catch (TimeoutException e) {
future.cancel(true);
throw new TimeoutException("Job timed out after " + timeoutMs + "ms");
} catch (ExecutionException e) {
throw (Exception) e.getCause();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new InterruptedException("Job was interrupted");
}
}
/**
* Checks if a job is queued.
*
* @param jobId The job ID
* @return true if the job is queued
*/
public boolean isJobQueued(String jobId) {
return jobMap.containsKey(jobId);
}
/**
* Gets the current position of a job in the queue.
*
* @param jobId The job ID
* @return The position (0-based) or -1 if not found
*/
public int getJobPosition(String jobId) {
if (!jobMap.containsKey(jobId)) {
return -1;
}
// Count positions
int position = 0;
for (QueuedJob job : jobQueue) {
if (job.jobId.equals(jobId)) {
return position;
}
position++;
}
// If we didn't find it in the queue but it's in the map,
// it might be executing already
return -1;
}
/**
* Cancels a queued job.
*
* @param jobId The job ID
* @return true if the job was cancelled, false if not found
*/
public boolean cancelJob(String jobId) {
QueuedJob job = jobMap.remove(jobId);
if (job != null) {
job.cancelled = true;
job.future.completeExceptionally(new RuntimeException("Job cancelled by user"));
// Try to remove from queue if it's still there
jobQueue.remove(job);
currentQueueSize = jobQueue.size();
log.debug("Job {} cancelled", jobId);
// Update WebSocket
webSocketSender.sendProgress(
jobId, new JobProgress(jobId, "Cancelled", 100, "Job cancelled by user"));
return true;
}
return false;
}
/**
* Get queue statistics.
*
* @return A map containing queue statistics
*/
public Map<String, Object> getQueueStats() {
return Map.of(
"queuedJobs", jobQueue.size(),
"queueCapacity", getQueueCapacity(),
"totalQueuedJobs", totalQueuedJobs,
"rejectedJobs", rejectedJobs,
"resourceStatus", resourceMonitor.getCurrentStatus().get().name());
}
}
@@ -0,0 +1,277 @@
package stirling.software.common.service;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.OperatingSystemMXBean;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
/**
* Monitors system resources (CPU, memory) to inform job scheduling decisions. Provides information
* about available resources to prevent overloading the system.
*/
@Service
@Slf4j
public class ResourceMonitor {
@Value("${stirling.resource.memory.critical-threshold:0.9}")
private double memoryCriticalThreshold = 0.9; // 90% usage is critical
@Value("${stirling.resource.memory.high-threshold:0.75}")
private double memoryHighThreshold = 0.75; // 75% usage is high
@Value("${stirling.resource.cpu.critical-threshold:0.9}")
private double cpuCriticalThreshold = 0.9; // 90% usage is critical
@Value("${stirling.resource.cpu.high-threshold:0.75}")
private double cpuHighThreshold = 0.75; // 75% usage is high
@Value("${stirling.resource.monitor.interval-ms:60000}")
private long monitorIntervalMs = 60000; // 60 seconds
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
@Getter
private final AtomicReference<ResourceStatus> currentStatus =
new AtomicReference<>(ResourceStatus.OK);
@Getter
private final AtomicReference<ResourceMetrics> latestMetrics =
new AtomicReference<>(new ResourceMetrics());
/** Represents the current status of system resources. */
public enum ResourceStatus {
/** Resources are available, normal operations can proceed */
OK,
/** Resources are under strain, consider queueing high-resource operations */
WARNING,
/** Resources are critically low, queue all operations */
CRITICAL
}
/** Detailed metrics about system resources. */
@Getter
public static class ResourceMetrics {
private final double cpuUsage;
private final double memoryUsage;
private final long freeMemoryBytes;
private final long totalMemoryBytes;
private final long maxMemoryBytes;
private final Instant timestamp;
public ResourceMetrics() {
this(0, 0, 0, 0, 0, Instant.now());
}
public ResourceMetrics(
double cpuUsage,
double memoryUsage,
long freeMemoryBytes,
long totalMemoryBytes,
long maxMemoryBytes,
Instant timestamp) {
this.cpuUsage = cpuUsage;
this.memoryUsage = memoryUsage;
this.freeMemoryBytes = freeMemoryBytes;
this.totalMemoryBytes = totalMemoryBytes;
this.maxMemoryBytes = maxMemoryBytes;
this.timestamp = timestamp;
}
/**
* Gets the age of these metrics.
*
* @return Duration since these metrics were collected
*/
public Duration getAge() {
return Duration.between(timestamp, Instant.now());
}
/**
* Check if these metrics are stale (older than threshold).
*
* @param thresholdMs Staleness threshold in milliseconds
* @return true if metrics are stale
*/
public boolean isStale(long thresholdMs) {
return getAge().toMillis() > thresholdMs;
}
}
@PostConstruct
public void initialize() {
log.debug("Starting resource monitoring with interval of {}ms", monitorIntervalMs);
scheduler.scheduleAtFixedRate(
this::updateResourceMetrics, 0, monitorIntervalMs, TimeUnit.MILLISECONDS);
}
@PreDestroy
public void shutdown() {
log.info("Shutting down resource monitoring");
scheduler.shutdownNow();
}
/** Updates the resource metrics by sampling current system state. */
private void updateResourceMetrics() {
try {
// Get CPU usage
double cpuUsage = osMXBean.getSystemLoadAverage() / osMXBean.getAvailableProcessors();
if (cpuUsage < 0) cpuUsage = getAlternativeCpuLoad(); // Fallback if not available
// Get memory usage
long heapUsed = memoryMXBean.getHeapMemoryUsage().getUsed();
long nonHeapUsed = memoryMXBean.getNonHeapMemoryUsage().getUsed();
long totalUsed = heapUsed + nonHeapUsed;
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
long freeMemory = Runtime.getRuntime().freeMemory();
double memoryUsage = (double) totalUsed / maxMemory;
// Create new metrics
ResourceMetrics metrics =
new ResourceMetrics(
cpuUsage,
memoryUsage,
freeMemory,
totalMemory,
maxMemory,
Instant.now());
latestMetrics.set(metrics);
// Determine system status
ResourceStatus newStatus;
if (cpuUsage > cpuCriticalThreshold || memoryUsage > memoryCriticalThreshold) {
newStatus = ResourceStatus.CRITICAL;
} else if (cpuUsage > cpuHighThreshold || memoryUsage > memoryHighThreshold) {
newStatus = ResourceStatus.WARNING;
} else {
newStatus = ResourceStatus.OK;
}
// Update status if it changed
ResourceStatus oldStatus = currentStatus.getAndSet(newStatus);
if (oldStatus != newStatus) {
log.info("System resource status changed from {} to {}", oldStatus, newStatus);
log.info(
"Current metrics - CPU: {:.1f}%, Memory: {:.1f}%, Free Memory: {} MB",
cpuUsage * 100, memoryUsage * 100, freeMemory / (1024 * 1024));
}
} catch (Exception e) {
log.error("Error updating resource metrics: {}", e.getMessage(), e);
}
}
/**
* Alternative method to estimate CPU load if getSystemLoadAverage() is not available. This is a
* fallback and less accurate than the official JMX method.
*
* @return Estimated CPU load as a value between 0.0 and 1.0
*/
private double getAlternativeCpuLoad() {
try {
// Try to get CPU time if available through reflection
// This is a fallback since we can't directly cast to platform-specific classes
try {
java.lang.reflect.Method m =
osMXBean.getClass().getDeclaredMethod("getProcessCpuLoad");
m.setAccessible(true);
return (double) m.invoke(osMXBean);
} catch (Exception e) {
// Try the older method
try {
java.lang.reflect.Method m =
osMXBean.getClass().getDeclaredMethod("getSystemCpuLoad");
m.setAccessible(true);
return (double) m.invoke(osMXBean);
} catch (Exception e2) {
log.trace(
"Could not get CPU load through reflection, assuming moderate load (0.5)");
return 0.5;
}
}
} catch (Exception e) {
log.trace("Could not get CPU load, assuming moderate load (0.5)");
return 0.5; // Default to moderate load
}
}
/**
* Calculates the dynamic job queue capacity based on current resource usage.
*
* @param baseCapacity The base capacity when system is under minimal load
* @param minCapacity The minimum capacity to maintain even under high load
* @return The calculated job queue capacity
*/
public int calculateDynamicQueueCapacity(int baseCapacity, int minCapacity) {
ResourceMetrics metrics = latestMetrics.get();
ResourceStatus status = currentStatus.get();
// Simple linear reduction based on memory and CPU load
double capacityFactor =
switch (status) {
case OK -> 1.0;
case WARNING -> 0.6;
case CRITICAL -> 0.3;
};
// Apply additional reduction based on specific memory pressure
if (metrics.memoryUsage > 0.8) {
capacityFactor *= 0.5; // Further reduce capacity under memory pressure
}
// Calculate capacity with minimum safeguard
int capacity = (int) Math.max(minCapacity, Math.ceil(baseCapacity * capacityFactor));
log.debug(
"Dynamic queue capacity: {} (base: {}, factor: {:.2f}, status: {})",
capacity,
baseCapacity,
capacityFactor,
status);
return capacity;
}
/**
* Checks if a job with the given weight can be executed immediately or should be queued based
* on current resource availability.
*
* @param resourceWeight The resource weight of the job (1-100)
* @return true if the job should be queued, false if it can run immediately
*/
public boolean shouldQueueJob(int resourceWeight) {
ResourceStatus status = currentStatus.get();
// Always run lightweight jobs (weight < 20) unless critical
if (resourceWeight < 20 && status != ResourceStatus.CRITICAL) {
return false;
}
// Medium weight jobs run immediately if resources are OK
if (resourceWeight < 60 && status == ResourceStatus.OK) {
return false;
}
// Heavy jobs (weight >= 60) and any job during WARNING/CRITICAL should be queued
return true;
}
}
@@ -0,0 +1,274 @@
package stirling.software.common.service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
/** Manages async tasks and their results */
@Service
@Slf4j
public class TaskManager {
private final Map<String, JobResult> jobResults = new ConcurrentHashMap<>();
@Value("${stirling.jobResultExpiryMinutes:30}")
private int jobResultExpiryMinutes = 30;
private final FileStorage fileStorage;
private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor();
/** Initialize the task manager and start the cleanup scheduler */
public TaskManager(FileStorage fileStorage) {
this.fileStorage = fileStorage;
// Schedule periodic cleanup of old job results
cleanupExecutor.scheduleAtFixedRate(
this::cleanupOldJobs,
10, // Initial delay
10, // Interval
TimeUnit.MINUTES);
log.debug(
"Task manager initialized with job result expiry of {} minutes",
jobResultExpiryMinutes);
}
/**
* Create a new task with the given job ID
*
* @param jobId The job ID
*/
public void createTask(String jobId) {
jobResults.put(jobId, JobResult.createNew(jobId));
log.debug("Created task with job ID: {}", jobId);
}
/**
* Set the result of a task as a general object
*
* @param jobId The job ID
* @param result The result object
*/
public void setResult(String jobId, Object result) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithResult(result);
log.debug("Set result for job ID: {}", jobId);
}
/**
* Set the result of a task as a file
*
* @param jobId The job ID
* @param fileId The file ID
* @param originalFileName The original file name
* @param contentType The content type of the file
*/
public void setFileResult(
String jobId, String fileId, String originalFileName, String contentType) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFile(fileId, originalFileName, contentType);
log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId);
}
/**
* Set an error for a task
*
* @param jobId The job ID
* @param error The error message
*/
public void setError(String jobId, String error) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.failWithError(error);
log.debug("Set error for job ID: {}: {}", jobId, error);
}
/**
* Mark a task as complete
*
* @param jobId The job ID
*/
public void setComplete(String jobId) {
JobResult jobResult = getOrCreateJobResult(jobId);
if (jobResult.getResult() == null
&& jobResult.getFileId() == null
&& jobResult.getError() == null) {
// If no result or error has been set, mark it as complete with an empty result
jobResult.completeWithResult("Task completed successfully");
}
log.debug("Marked job ID: {} as complete", jobId);
}
/**
* Check if a task is complete
*
* @param jobId The job ID
* @return true if the task is complete, false otherwise
*/
public boolean isComplete(String jobId) {
JobResult result = jobResults.get(jobId);
return result != null && result.isComplete();
}
/**
* Get the result of a task
*
* @param jobId The job ID
* @return The result object, or null if the task doesn't exist or is not complete
*/
public JobResult getJobResult(String jobId) {
return jobResults.get(jobId);
}
/**
* Get statistics about all jobs in the system
*
* @return Job statistics
*/
public JobStats getJobStats() {
int totalJobs = jobResults.size();
int activeJobs = 0;
int completedJobs = 0;
int failedJobs = 0;
int successfulJobs = 0;
int fileResultJobs = 0;
LocalDateTime oldestActiveJobTime = null;
LocalDateTime newestActiveJobTime = null;
long totalProcessingTimeMs = 0;
for (JobResult result : jobResults.values()) {
if (result.isComplete()) {
completedJobs++;
// Calculate processing time for completed jobs
if (result.getCreatedAt() != null && result.getCompletedAt() != null) {
long processingTimeMs =
java.time.Duration.between(
result.getCreatedAt(), result.getCompletedAt())
.toMillis();
totalProcessingTimeMs += processingTimeMs;
}
if (result.getError() != null) {
failedJobs++;
} else {
successfulJobs++;
if (result.getFileId() != null) {
fileResultJobs++;
}
}
} else {
activeJobs++;
// Track oldest and newest active jobs
if (result.getCreatedAt() != null) {
if (oldestActiveJobTime == null
|| result.getCreatedAt().isBefore(oldestActiveJobTime)) {
oldestActiveJobTime = result.getCreatedAt();
}
if (newestActiveJobTime == null
|| result.getCreatedAt().isAfter(newestActiveJobTime)) {
newestActiveJobTime = result.getCreatedAt();
}
}
}
}
// Calculate average processing time
long averageProcessingTimeMs =
completedJobs > 0 ? totalProcessingTimeMs / completedJobs : 0;
return JobStats.builder()
.totalJobs(totalJobs)
.activeJobs(activeJobs)
.completedJobs(completedJobs)
.failedJobs(failedJobs)
.successfulJobs(successfulJobs)
.fileResultJobs(fileResultJobs)
.oldestActiveJobTime(oldestActiveJobTime)
.newestActiveJobTime(newestActiveJobTime)
.averageProcessingTimeMs(averageProcessingTimeMs)
.build();
}
/**
* Get or create a job result
*
* @param jobId The job ID
* @return The job result
*/
private JobResult getOrCreateJobResult(String jobId) {
return jobResults.computeIfAbsent(jobId, JobResult::createNew);
}
/** Clean up old completed job results */
public void cleanupOldJobs() {
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
try {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult result = entry.getValue();
// Remove completed jobs that are older than the expiry threshold
if (result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold)) {
// If the job has a file result, delete the file
if (result.getFileId() != null) {
try {
fileStorage.deleteFile(result.getFileId());
} catch (Exception e) {
log.warn(
"Failed to delete file for job {}: {}",
entry.getKey(),
e.getMessage());
}
}
// Remove the job result
jobResults.remove(entry.getKey());
removedCount++;
}
}
if (removedCount > 0) {
log.info("Cleaned up {} expired job results", removedCount);
}
} catch (Exception e) {
log.error("Error during job cleanup: {}", e.getMessage(), e);
}
}
/** Shutdown the cleanup executor */
@PreDestroy
public void shutdown() {
try {
log.info("Shutting down job result cleanup executor");
cleanupExecutor.shutdown();
if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
cleanupExecutor.shutdownNow();
}
}
}
@@ -0,0 +1,31 @@
package stirling.software.common.util;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ExecutorFactory {
/**
* Creates an ExecutorService using virtual threads if available (Java 21+), or falls back to a
* cached thread pool on older Java versions.
*/
public static ExecutorService newVirtualOrCachedThreadExecutor() {
try {
ExecutorService executor =
(ExecutorService)
Executors.class
.getMethod("newVirtualThreadPerTaskExecutor")
.invoke(null);
return executor;
} catch (NoSuchMethodException e) {
log.debug("Virtual threads not available; falling back to cached thread pool.");
} catch (Exception e) {
log.debug("Error initializing virtual thread executor: {}", e.getMessage(), e);
}
return Executors.newCachedThreadPool();
}
}
@@ -0,0 +1,214 @@
package stirling.software.common.annotations;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import java.util.Arrays;
import java.util.function.Supplier;
import org.aspectj.lang.ProceedingJoinPoint;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.aop.AutoJobAspect;
import stirling.software.common.controller.WebSocketProgressController;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileOrUploadService;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.ResourceMonitor;
@ExtendWith(MockitoExtension.class)
class AutoJobPostMappingIntegrationTest {
private AutoJobAspect autoJobAspect;
@Mock
private JobExecutorService jobExecutorService;
@Mock
private HttpServletRequest request;
@Mock
private FileOrUploadService fileOrUploadService;
@Mock
private FileStorage fileStorage;
@Mock
private WebSocketProgressController webSocketSender;
@Mock
private ResourceMonitor resourceMonitor;
@Mock
private JobQueue jobQueue;
@BeforeEach
void setUp() {
autoJobAspect = new AutoJobAspect(
jobExecutorService,
request,
fileOrUploadService,
fileStorage,
webSocketSender
);
}
@Mock
private ProceedingJoinPoint joinPoint;
@Mock
private AutoJobPostMapping autoJobPostMapping;
@Captor
private ArgumentCaptor<Supplier<Object>> workCaptor;
@Captor
private ArgumentCaptor<Boolean> asyncCaptor;
@Captor
private ArgumentCaptor<Long> timeoutCaptor;
@Captor
private ArgumentCaptor<Boolean> queueableCaptor;
@Captor
private ArgumentCaptor<Integer> resourceWeightCaptor;
@Test
void shouldExecuteWithCustomParameters() throws Throwable {
// Given
PDFFile pdfFile = new PDFFile();
pdfFile.setFileId("test-file-id");
Object[] args = new Object[] { pdfFile };
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
when(autoJobPostMapping.timeout()).thenReturn(60000L);
when(autoJobPostMapping.retryCount()).thenReturn(3);
when(autoJobPostMapping.trackProgress()).thenReturn(true);
when(autoJobPostMapping.queueable()).thenReturn(true);
when(autoJobPostMapping.resourceWeight()).thenReturn(75);
MultipartFile mockFile = mock(MultipartFile.class);
when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile);
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
assertEquals(ResponseEntity.ok("success"), result);
verify(jobExecutorService).runJobGeneric(
asyncCaptor.capture(),
workCaptor.capture(),
timeoutCaptor.capture(),
queueableCaptor.capture(),
resourceWeightCaptor.capture());
assertTrue(asyncCaptor.getValue(), "Async should be true");
assertEquals(60000L, timeoutCaptor.getValue(), "Timeout should be 60000ms");
assertTrue(queueableCaptor.getValue(), "Queueable should be true");
assertEquals(75, resourceWeightCaptor.getValue(), "Resource weight should be 75");
// Test that file was resolved
assertNotNull(pdfFile.getFileInput(), "File input should be set");
}
@Test
void shouldRetryOnError() throws Throwable {
// Given
when(joinPoint.getArgs()).thenReturn(new Object[0]);
when(request.getParameter("async")).thenReturn("false");
when(autoJobPostMapping.timeout()).thenReturn(-1L);
when(autoJobPostMapping.retryCount()).thenReturn(2);
when(autoJobPostMapping.trackProgress()).thenReturn(false);
when(autoJobPostMapping.queueable()).thenReturn(false);
when(autoJobPostMapping.resourceWeight()).thenReturn(50);
// First call throws exception, second succeeds
when(joinPoint.proceed(any()))
.thenThrow(new RuntimeException("First attempt failed"))
.thenReturn(ResponseEntity.ok("retry succeeded"));
// Mock jobExecutorService to execute the work immediately
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenAnswer(invocation -> {
Supplier<Object> work = invocation.getArgument(1);
return work.get();
});
// When
Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
assertEquals(ResponseEntity.ok("retry succeeded"), result);
// Verify that proceed was called twice (initial attempt + 1 retry)
verify(joinPoint, times(2)).proceed(any());
}
@Test
void shouldHandlePDFFileWithAsyncRequests() throws Throwable {
// Given
PDFFile pdfFile = new PDFFile();
pdfFile.setFileInput(mock(MultipartFile.class));
Object[] args = new Object[] { pdfFile };
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
when(autoJobPostMapping.retryCount()).thenReturn(1);
when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("stored-file-id");
when(fileStorage.retrieveFile("stored-file-id")).thenReturn(mock(MultipartFile.class));
// Mock job executor to return a successful response
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
assertEquals("stored-file-id", pdfFile.getFileId(),
"FileId should be set to the stored file id");
assertNotNull(pdfFile.getFileInput(), "FileInput should be replaced with persistent file");
// Verify storage operations
verify(fileStorage).storeFile(any(MultipartFile.class));
verify(fileStorage).retrieveFile("stored-file-id");
}
}
// Move PDFFileTest to its own class file if needed
@@ -0,0 +1,227 @@
package stirling.software.common.controller;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.TaskManager;
class JobControllerTest {
@Mock
private TaskManager taskManager;
@Mock
private FileStorage fileStorage;
@InjectMocks
private JobController controller;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void testGetJobStatus_ExistingJob() {
// Arrange
String jobId = "test-job-id";
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
// Act
ResponseEntity<?> response = controller.getJobStatus(jobId);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(mockResult, response.getBody());
}
@Test
void testGetJobStatus_NonExistentJob() {
// Arrange
String jobId = "non-existent-job";
when(taskManager.getJobResult(jobId)).thenReturn(null);
// Act
ResponseEntity<?> response = controller.getJobStatus(jobId);
// Assert
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
}
@Test
void testGetJobResult_CompletedSuccessfulWithObject() {
// Arrange
String jobId = "test-job-id";
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.setComplete(true);
String resultObject = "Test result";
mockResult.completeWithResult(resultObject);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
// Act
ResponseEntity<?> response = controller.getJobResult(jobId);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(resultObject, response.getBody());
}
@Test
void testGetJobResult_CompletedSuccessfulWithFile() throws Exception {
// Arrange
String jobId = "test-job-id";
String fileId = "file-id";
String originalFileName = "test.pdf";
String contentType = "application/pdf";
byte[] fileContent = "Test file content".getBytes();
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.completeWithFile(fileId, originalFileName, contentType);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
when(fileStorage.retrieveBytes(fileId)).thenReturn(fileContent);
// Act
ResponseEntity<?> response = controller.getJobResult(jobId);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(contentType, response.getHeaders().getFirst("Content-Type"));
assertTrue(response.getHeaders().getFirst("Content-Disposition").contains(originalFileName));
assertEquals(fileContent, response.getBody());
}
@Test
void testGetJobResult_CompletedWithError() {
// Arrange
String jobId = "test-job-id";
String errorMessage = "Test error";
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.failWithError(errorMessage);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
// Act
ResponseEntity<?> response = controller.getJobResult(jobId);
// Assert
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertTrue(response.getBody().toString().contains(errorMessage));
}
@Test
void testGetJobResult_IncompleteJob() {
// Arrange
String jobId = "test-job-id";
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.setComplete(false);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
// Act
ResponseEntity<?> response = controller.getJobResult(jobId);
// Assert
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertTrue(response.getBody().toString().contains("not complete"));
}
@Test
void testGetJobResult_NonExistentJob() {
// Arrange
String jobId = "non-existent-job";
when(taskManager.getJobResult(jobId)).thenReturn(null);
// Act
ResponseEntity<?> response = controller.getJobResult(jobId);
// Assert
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
}
@Test
void testGetJobResult_ErrorRetrievingFile() throws Exception {
// Arrange
String jobId = "test-job-id";
String fileId = "file-id";
String originalFileName = "test.pdf";
String contentType = "application/pdf";
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.completeWithFile(fileId, originalFileName, contentType);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
when(fileStorage.retrieveBytes(fileId)).thenThrow(new RuntimeException("File not found"));
// Act
ResponseEntity<?> response = controller.getJobResult(jobId);
// Assert
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertTrue(response.getBody().toString().contains("Error retrieving file"));
}
@Test
void testGetJobStats() {
// Arrange
JobStats mockStats = JobStats.builder()
.totalJobs(10)
.activeJobs(3)
.completedJobs(7)
.build();
when(taskManager.getJobStats()).thenReturn(mockStats);
// Act
ResponseEntity<JobStats> response = controller.getJobStats();
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(mockStats, response.getBody());
}
@Test
void testCleanupOldJobs() {
// Arrange
when(taskManager.getJobStats())
.thenReturn(JobStats.builder().totalJobs(10).build())
.thenReturn(JobStats.builder().totalJobs(7).build());
// Act
ResponseEntity<?> response = controller.cleanupOldJobs();
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = (Map<String, Object>) response.getBody();
assertEquals("Cleanup complete", responseBody.get("message"));
assertEquals(3, responseBody.get("removedJobs"));
assertEquals(7, responseBody.get("remainingJobs"));
verify(taskManager).cleanupOldJobs();
}
}
@@ -0,0 +1,69 @@
package stirling.software.common.controller;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import stirling.software.common.model.job.JobProgress;
class WebSocketProgressControllerTest {
@Mock
private SimpMessagingTemplate messagingTemplate;
@InjectMocks
private WebSocketProgressController controller;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void testSendProgress_WithMessagingTemplate() {
// Arrange
String jobId = "test-job-id";
JobProgress progress = new JobProgress(jobId, "In Progress", 50, "Processing");
// Act
controller.sendProgress(jobId, progress);
// Assert
verify(messagingTemplate).convertAndSend("/topic/progress/" + jobId, progress);
}
@Test
void testSendProgress_WithNullMessagingTemplate() {
// Arrange
WebSocketProgressController controllerWithNullTemplate = new WebSocketProgressController();
String jobId = "test-job-id";
JobProgress progress = new JobProgress(jobId, "In Progress", 50, "Processing");
// Act - should not throw exception even with null template
controllerWithNullTemplate.sendProgress(jobId, progress);
// No assertion needed - test passes if no exception is thrown
}
@Test
void testSetMessagingTemplate() {
// Arrange
WebSocketProgressController newController = new WebSocketProgressController();
SimpMessagingTemplate newTemplate = mock(SimpMessagingTemplate.class);
// Act
newController.setMessagingTemplate(newTemplate);
String jobId = "test-job-id";
JobProgress progress = new JobProgress(jobId, "In Progress", 50, "Processing");
newController.sendProgress(jobId, progress);
// Assert
verify(newTemplate).convertAndSend("/topic/progress/" + jobId, progress);
}
}
@@ -0,0 +1,190 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.AdditionalAnswers.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
class FileStorageTest {
@TempDir
Path tempDir;
@Mock
private FileOrUploadService fileOrUploadService;
@InjectMocks
private FileStorage fileStorage;
private MultipartFile mockFile;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString());
// Create a mock MultipartFile
mockFile = mock(MultipartFile.class);
when(mockFile.getOriginalFilename()).thenReturn("test.pdf");
when(mockFile.getContentType()).thenReturn("application/pdf");
}
@Test
void testStoreFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
when(mockFile.getBytes()).thenReturn(fileContent);
// Set up mock to handle transferTo by writing the file
doAnswer(invocation -> {
java.io.File file = invocation.getArgument(0);
Files.write(file.toPath(), fileContent);
return null;
}).when(mockFile).transferTo(any(java.io.File.class));
// Act
String fileId = fileStorage.storeFile(mockFile);
// Assert
assertNotNull(fileId);
assertTrue(Files.exists(tempDir.resolve(fileId)));
verify(mockFile).transferTo(any(java.io.File.class));
}
@Test
void testStoreBytes() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String originalName = "test.pdf";
// Act
String fileId = fileStorage.storeBytes(fileContent, originalName);
// Assert
assertNotNull(fileId);
assertTrue(Files.exists(tempDir.resolve(fileId)));
assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId)));
}
@Test
void testRetrieveFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
MultipartFile expectedFile = mock(MultipartFile.class);
when(fileOrUploadService.toMockMultipartFile(eq(fileId), eq(fileContent)))
.thenReturn(expectedFile);
// Act
MultipartFile result = fileStorage.retrieveFile(fileId);
// Assert
assertSame(expectedFile, result);
verify(fileOrUploadService).toMockMultipartFile(eq(fileId), eq(fileContent));
}
@Test
void testRetrieveBytes() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
// Act
byte[] result = fileStorage.retrieveBytes(fileId);
// Assert
assertArrayEquals(fileContent, result);
}
@Test
void testRetrieveFile_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
// Act & Assert
assertThrows(IOException.class, () -> fileStorage.retrieveFile(nonExistentFileId));
}
@Test
void testRetrieveBytes_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
// Act & Assert
assertThrows(IOException.class, () -> fileStorage.retrieveBytes(nonExistentFileId));
}
@Test
void testDeleteFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
// Act
boolean result = fileStorage.deleteFile(fileId);
// Assert
assertTrue(result);
assertFalse(Files.exists(filePath));
}
@Test
void testDeleteFile_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
// Act
boolean result = fileStorage.deleteFile(nonExistentFileId);
// Assert
assertFalse(result);
}
@Test
void testFileExists() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
// Act
boolean result = fileStorage.fileExists(fileId);
// Assert
assertTrue(result);
}
@Test
void testFileExists_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
// Act
boolean result = fileStorage.fileExists(nonExistentFileId);
// Assert
assertFalse(result);
}
}
@@ -0,0 +1,208 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.controller.WebSocketProgressController;
import stirling.software.common.model.job.JobProgress;
import stirling.software.common.model.job.JobResponse;
@ExtendWith(MockitoExtension.class)
class JobExecutorServiceTest {
private JobExecutorService jobExecutorService;
@Mock
private TaskManager taskManager;
@Mock
private WebSocketProgressController webSocketSender;
@Mock
private FileStorage fileStorage;
@Mock
private HttpServletRequest request;
@Mock
private ResourceMonitor resourceMonitor;
@Mock
private JobQueue jobQueue;
@Captor
private ArgumentCaptor<String> jobIdCaptor;
@BeforeEach
void setUp() {
// Initialize the service manually with all its dependencies
jobExecutorService = new JobExecutorService(
taskManager,
webSocketSender,
fileStorage,
request,
resourceMonitor,
jobQueue,
30000L, // asyncRequestTimeoutMs
"30m" // sessionTimeout
);
}
@Test
void shouldRunSyncJobSuccessfully() throws Exception {
// Given
Supplier<Object> work = () -> "test-result";
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("test-result", response.getBody());
// Verify request attribute was set with jobId
verify(request).setAttribute(eq("jobId"), anyString());
}
@Test
void shouldRunAsyncJobSuccessfully() throws Exception {
// Given
Supplier<Object> work = () -> "test-result";
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(true, work);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(response.getBody() instanceof JobResponse);
JobResponse<?> jobResponse = (JobResponse<?>) response.getBody();
assertTrue(jobResponse.isAsync());
assertNotNull(jobResponse.getJobId());
// Verify task manager was called
verify(taskManager).createTask(jobIdCaptor.capture());
verify(webSocketSender, Mockito.atLeastOnce()).sendProgress(eq(jobIdCaptor.getValue()), any(JobProgress.class));
}
@Test
void shouldHandleSyncJobError() {
// Given
Supplier<Object> work = () -> {
throw new RuntimeException("Test error");
};
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
// Then
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, String> errorMap = (Map<String, String>) response.getBody();
assertEquals("Job failed: Test error", errorMap.get("error"));
}
@Test
void shouldQueueJobWhenResourcesLimited() {
// Given
Supplier<Object> work = () -> "test-result";
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
// Configure resourceMonitor to indicate job should be queued
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
// Configure jobQueue to return our future
when(jobQueue.queueJob(anyString(), eq(80), any(), anyLong())).thenReturn(future);
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(
true, work, 5000, true, 80);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(response.getBody() instanceof JobResponse);
// Verify job was queued
verify(jobQueue).queueJob(anyString(), eq(80), any(), eq(5000L));
verify(taskManager).createTask(anyString());
}
@Test
void shouldUseCustomTimeoutWhenProvided() throws Exception {
// Given
Supplier<Object> work = () -> "test-result";
long customTimeout = 60000L;
// Use reflection to access the private executeWithTimeout method
java.lang.reflect.Method executeMethod = JobExecutorService.class
.getDeclaredMethod("executeWithTimeout", Supplier.class, long.class);
executeMethod.setAccessible(true);
// Create a spy on the JobExecutorService to verify method calls
JobExecutorService spy = Mockito.spy(jobExecutorService);
// When
spy.runJobGeneric(false, work, customTimeout);
// Then
verify(spy).runJobGeneric(eq(false), any(Supplier.class), eq(customTimeout));
}
@Test
void shouldHandleTimeout() throws Exception {
// Given
Supplier<Object> work = () -> {
try {
Thread.sleep(100); // Simulate long-running job
return "test-result";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
};
// Use reflection to access the private executeWithTimeout method
java.lang.reflect.Method executeMethod = JobExecutorService.class
.getDeclaredMethod("executeWithTimeout", Supplier.class, long.class);
executeMethod.setAccessible(true);
// When/Then
try {
executeMethod.invoke(jobExecutorService, work, 1L); // Very short timeout
} catch (Exception e) {
assertTrue(e.getCause() instanceof TimeoutException);
}
}
}
@@ -0,0 +1,107 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.controller.WebSocketProgressController;
import stirling.software.common.model.job.JobProgress;
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
@ExtendWith(MockitoExtension.class)
class JobQueueTest {
private JobQueue jobQueue;
@Mock
private ResourceMonitor resourceMonitor;
@Mock
private WebSocketProgressController webSocketSender;
private final AtomicReference<ResourceStatus> statusRef = new AtomicReference<>(ResourceStatus.OK);
@BeforeEach
void setUp() {
// Mark stubbing as lenient to avoid UnnecessaryStubbingException
lenient().when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(10);
lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
jobQueue = new JobQueue(resourceMonitor, webSocketSender);
}
@Test
void shouldQueueJob() {
String jobId = "test-job-1";
int resourceWeight = 50;
Supplier<Object> work = () -> "test-result";
long timeoutMs = 1000;
jobQueue.queueJob(jobId, resourceWeight, work, timeoutMs);
verify(webSocketSender).sendProgress(
org.mockito.ArgumentMatchers.eq(jobId),
org.mockito.ArgumentMatchers.any(JobProgress.class));
assertTrue(jobQueue.isJobQueued(jobId));
assertEquals(1, jobQueue.getTotalQueuedJobs());
}
@Test
void shouldCancelJob() {
String jobId = "test-job-2";
Supplier<Object> work = () -> "test-result";
jobQueue.queueJob(jobId, 50, work, 1000);
boolean cancelled = jobQueue.cancelJob(jobId);
assertTrue(cancelled);
assertFalse(jobQueue.isJobQueued(jobId));
}
@Test
void shouldGetQueueStats() {
when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
jobQueue.queueJob("job1", 50, () -> "ok", 1000);
jobQueue.queueJob("job2", 50, () -> "ok", 1000);
jobQueue.cancelJob("job2");
Map<String, Object> stats = jobQueue.getQueueStats();
assertEquals(2, stats.get("totalQueuedJobs"));
assertTrue(stats.containsKey("queuedJobs"));
assertTrue(stats.containsKey("resourceStatus"));
}
@Test
void shouldCalculateQueueCapacity() {
when(resourceMonitor.calculateDynamicQueueCapacity(5, 2)).thenReturn(8);
int capacity = resourceMonitor.calculateDynamicQueueCapacity(5, 2);
assertEquals(8, capacity);
}
@Test
void shouldCheckIfJobIsQueued() {
String jobId = "job-123";
Supplier<Object> work = () -> "hello";
jobQueue.queueJob(jobId, 40, work, 500);
assertTrue(jobQueue.isJobQueued(jobId));
assertFalse(jobQueue.isJobQueued("nonexistent"));
}
}
@@ -0,0 +1,137 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.lang.management.OperatingSystemMXBean;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.service.ResourceMonitor.ResourceMetrics;
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
@ExtendWith(MockitoExtension.class)
class ResourceMonitorTest {
@InjectMocks
private ResourceMonitor resourceMonitor;
@Mock
private OperatingSystemMXBean osMXBean;
@Mock
private MemoryMXBean memoryMXBean;
@Spy
private AtomicReference<ResourceStatus> currentStatus = new AtomicReference<>(ResourceStatus.OK);
@Spy
private AtomicReference<ResourceMetrics> latestMetrics = new AtomicReference<>(new ResourceMetrics());
@BeforeEach
void setUp() {
// Set thresholds for testing
ReflectionTestUtils.setField(resourceMonitor, "memoryCriticalThreshold", 0.9);
ReflectionTestUtils.setField(resourceMonitor, "memoryHighThreshold", 0.75);
ReflectionTestUtils.setField(resourceMonitor, "cpuCriticalThreshold", 0.9);
ReflectionTestUtils.setField(resourceMonitor, "cpuHighThreshold", 0.75);
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", osMXBean);
ReflectionTestUtils.setField(resourceMonitor, "memoryMXBean", memoryMXBean);
ReflectionTestUtils.setField(resourceMonitor, "currentStatus", currentStatus);
ReflectionTestUtils.setField(resourceMonitor, "latestMetrics", latestMetrics);
}
@Test
void shouldCalculateDynamicQueueCapacity() {
// Given
int baseCapacity = 10;
int minCapacity = 2;
// Mock current status as OK
currentStatus.set(ResourceStatus.OK);
// When
int capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity);
// Then
assertEquals(baseCapacity, capacity, "With OK status, capacity should equal base capacity");
// Given
currentStatus.set(ResourceStatus.WARNING);
// When
capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity);
// Then
assertEquals(6, capacity, "With WARNING status, capacity should be reduced to 60%");
// Given
currentStatus.set(ResourceStatus.CRITICAL);
// When
capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity);
// Then
assertEquals(3, capacity, "With CRITICAL status, capacity should be reduced to 30%");
// Test minimum capacity enforcement
assertEquals(minCapacity, resourceMonitor.calculateDynamicQueueCapacity(1, minCapacity),
"Should never go below minimum capacity");
}
@ParameterizedTest
@CsvSource({
"10, OK, false", // Light job, OK status
"10, WARNING, false", // Light job, WARNING status
"10, CRITICAL, true", // Light job, CRITICAL status
"30, OK, false", // Medium job, OK status
"30, WARNING, true", // Medium job, WARNING status
"30, CRITICAL, true", // Medium job, CRITICAL status
"80, OK, true", // Heavy job, OK status
"80, WARNING, true", // Heavy job, WARNING status
"80, CRITICAL, true" // Heavy job, CRITICAL status
})
void shouldQueueJobBasedOnWeightAndStatus(int weight, ResourceStatus status, boolean shouldQueue) {
// Given
currentStatus.set(status);
// When
boolean result = resourceMonitor.shouldQueueJob(weight);
// Then
assertEquals(shouldQueue, result,
String.format("For weight %d and status %s, shouldQueue should be %s",
weight, status, shouldQueue));
}
@Test
void resourceMetricsShouldDetectStaleState() {
// Given
Instant now = Instant.now();
Instant pastInstant = now.minusMillis(6000);
ResourceMetrics staleMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, pastInstant);
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now);
// When/Then
assertTrue(staleMetrics.isStale(5000), "Metrics from 6 seconds ago should be stale with 5s threshold");
assertFalse(freshMetrics.isStale(5000), "Fresh metrics should not be stale");
}
}
@@ -0,0 +1,255 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
class TaskManagerTest {
@Mock
private FileStorage fileStorage;
@InjectMocks
private TaskManager taskManager;
private AutoCloseable closeable;
@BeforeEach
void setUp() {
closeable = MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
}
@AfterEach
void tearDown() throws Exception {
closeable.close();
}
@Test
void testCreateTask() {
// Act
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertEquals(jobId, result.getJobId());
assertFalse(result.isComplete());
assertNotNull(result.getCreatedAt());
}
@Test
void testSetResult() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
Object resultObject = "Test result";
// Act
taskManager.setResult(jobId, resultObject);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(resultObject, result.getResult());
assertNotNull(result.getCompletedAt());
}
@Test
void testSetFileResult() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
String fileId = "file-id";
String originalFileName = "test.pdf";
String contentType = "application/pdf";
// Act
taskManager.setFileResult(jobId, fileId, originalFileName, contentType);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(fileId, result.getFileId());
assertEquals(originalFileName, result.getOriginalFileName());
assertEquals(contentType, result.getContentType());
assertNotNull(result.getCompletedAt());
}
@Test
void testSetError() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
String errorMessage = "Test error";
// Act
taskManager.setError(jobId, errorMessage);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(errorMessage, result.getError());
assertNotNull(result.getCompletedAt());
}
@Test
void testSetComplete_WithExistingResult() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
Object resultObject = "Test result";
taskManager.setResult(jobId, resultObject);
// Act
taskManager.setComplete(jobId);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(resultObject, result.getResult());
}
@Test
void testSetComplete_WithoutExistingResult() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
// Act
taskManager.setComplete(jobId);
// Assert
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals("Task completed successfully", result.getResult());
}
@Test
void testIsComplete() {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
// Assert - not complete initially
assertFalse(taskManager.isComplete(jobId));
// Act - mark as complete
taskManager.setComplete(jobId);
// Assert - now complete
assertTrue(taskManager.isComplete(jobId));
}
@Test
void testGetJobStats() {
// Arrange
// 1. Create active job
String activeJobId = "active-job";
taskManager.createTask(activeJobId);
// 2. Create completed successful job with file
String successFileJobId = "success-file-job";
taskManager.createTask(successFileJobId);
taskManager.setFileResult(successFileJobId, "file-id", "test.pdf", "application/pdf");
// 3. Create completed successful job without file
String successJobId = "success-job";
taskManager.createTask(successJobId);
taskManager.setResult(successJobId, "Result");
// 4. Create failed job
String failedJobId = "failed-job";
taskManager.createTask(failedJobId);
taskManager.setError(failedJobId, "Error message");
// Act
JobStats stats = taskManager.getJobStats();
// Assert
assertEquals(4, stats.getTotalJobs());
assertEquals(1, stats.getActiveJobs());
assertEquals(3, stats.getCompletedJobs());
assertEquals(1, stats.getFailedJobs());
assertEquals(2, stats.getSuccessfulJobs());
assertEquals(1, stats.getFileResultJobs());
assertNotNull(stats.getNewestActiveJobTime());
assertNotNull(stats.getOldestActiveJobTime());
assertTrue(stats.getAverageProcessingTimeMs() >= 0);
}
@Test
void testCleanupOldJobs() throws Exception {
// Arrange
// 1. Create a recent completed job
String recentJobId = "recent-job";
taskManager.createTask(recentJobId);
taskManager.setResult(recentJobId, "Result");
// 2. Create an old completed job with file result
String oldJobId = "old-job";
taskManager.createTask(oldJobId);
JobResult oldJob = taskManager.getJobResult(oldJobId);
// Manually set the completion time to be older than the expiry
LocalDateTime oldTime = LocalDateTime.now().minusHours(1);
ReflectionTestUtils.setField(oldJob, "completedAt", oldTime);
ReflectionTestUtils.setField(oldJob, "complete", true);
ReflectionTestUtils.setField(oldJob, "fileId", "file-id");
ReflectionTestUtils.setField(oldJob, "originalFileName", "test.pdf");
ReflectionTestUtils.setField(oldJob, "contentType", "application/pdf");
when(fileStorage.deleteFile("file-id")).thenReturn(true);
// Obtain access to the private jobResults map
Map<String, JobResult> jobResultsMap = (Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
// 3. Create an active job
String activeJobId = "active-job";
taskManager.createTask(activeJobId);
// Verify all jobs are in the map
assertTrue(jobResultsMap.containsKey(recentJobId));
assertTrue(jobResultsMap.containsKey(oldJobId));
assertTrue(jobResultsMap.containsKey(activeJobId));
// Act
taskManager.cleanupOldJobs();
// Assert - the old job should be removed
assertFalse(jobResultsMap.containsKey(oldJobId));
assertTrue(jobResultsMap.containsKey(recentJobId));
assertTrue(jobResultsMap.containsKey(activeJobId));
verify(fileStorage).deleteFile("file-id");
}
@Test
void testShutdown() throws Exception {
// This mainly tests that the shutdown method doesn't throw exceptions
taskManager.shutdown();
// Verify the executor service is shutdown
// This is difficult to test directly, but we can verify it doesn't throw exceptions
}
}
+168
View File
@@ -0,0 +1,168 @@
# AutoJobPostMapping Annotation
The `AutoJobPostMapping` annotation simplifies the creation of job-based REST endpoints in Stirling-PDF. It automatically handles job execution, file persistence, error handling, retries, and progress tracking.
## Features
- Wraps endpoint methods with job execution logic
- Supports both synchronous and asynchronous execution (via `?async=true` query parameter)
- Custom timeout configuration per endpoint
- Automatic retries with configurable retry count
- WebSocket-based progress tracking
- Consistent error handling and reporting
- Automatic persistence of uploaded files for async processing
## Usage
```java
@AutoJobPostMapping("/api/v1/security/remove-password")
public ResponseEntity<byte[]> removePassword(@ModelAttribute PDFPasswordRequest request)
throws IOException {
MultipartFile fileInput = request.getFileInput();
String password = request.getPassword();
PDDocument document = pdfDocumentFactory.load(fileInput, password);
document.setAllSecurityToBeRemoved(true);
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(fileInput.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_password_removed.pdf");
}
```
## Parameters
The `AutoJobPostMapping` annotation accepts the following parameters:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `value` | String[] | `{}` | The path mapping URIs (e.g., "/api/v1/security/add-password") |
| `consumes` | String[] | `{"multipart/form-data"}` | Supported media types for requests |
| `timeout` | long | `-1` (use system default) | Custom timeout in milliseconds for this job |
| `retryCount` | int | `1` (no retries) | Maximum number of retry attempts on failure |
| `trackProgress` | boolean | `true` | Enable WebSocket progress tracking for async jobs |
| `queueable` | boolean | `false` | Whether this job can be queued when system resources are limited |
| `resourceWeight` | int | `50` | Resource weight of this job (1-100), higher values indicate more resource-intensive jobs |
## Examples
### Basic Usage
```java
@AutoJobPostMapping("/api/v1/security/remove-password")
public ResponseEntity<byte[]> removePassword(@ModelAttribute PDFPasswordRequest request) {
// Implementation
}
```
### With Custom Timeout
```java
// Set a 5-minute timeout for this operation
@AutoJobPostMapping(value = "/api/v1/misc/ocr-pdf", timeout = 300000)
public ResponseEntity<byte[]> ocrPdf(@ModelAttribute OCRRequest request) {
// OCR implementation
}
```
### With Retries
```java
// Allow up to 3 retry attempts for external API calls
@AutoJobPostMapping(value = "/api/v1/convert/url-to-pdf", retryCount = 3)
public ResponseEntity<byte[]> convertUrlToPdf(@ModelAttribute WebsiteToPDFRequest request) {
// Implementation
}
```
### Disable Progress Tracking
```java
// Simple, fast operation that doesn't need progress tracking
@AutoJobPostMapping(value = "/api/v1/misc/flatten", trackProgress = false)
public ResponseEntity<byte[]> flattenPdf(@ModelAttribute FlattenRequest request) {
// Implementation
}
```
### Enable Job Queueing for Resource-Intensive Operations
```java
// Resource-intensive operation that can be queued during high system load
@AutoJobPostMapping(
value = "/api/v1/misc/ocr-pdf",
queueable = true,
resourceWeight = 80, // High resource usage
timeout = 600000 // 10 minutes
)
public ResponseEntity<byte[]> ocrPdf(@ModelAttribute OCRRequest request) {
// OCR implementation
}
```
### Lightweight Operation
```java
// Very lightweight operation with low resource requirements
@AutoJobPostMapping(
value = "/api/v1/misc/get-page-count",
queueable = false,
resourceWeight = 10 // Very low resource usage
)
public ResponseEntity<Integer> getPageCount(@ModelAttribute PDFFile request) {
// Simple page count implementation
}
```
## Client-Side Integration
For asynchronous jobs, clients can:
1. Submit the job with `?async=true` parameter
2. Receive a job ID in the response
3. Connect to the WebSocket at `/ws/progress/{jobId}` to receive progress updates
4. Fetch the completed result from `/api/v1/general/job/{jobId}/result` when done
Example WebSocket message:
```json
{
"jobId": "b4c9a31d-4b7e-42b2-8ab9-3cbe99d5b94f",
"status": "Processing",
"progress": 65,
"message": "OCR processing page 13/20"
}
```
## Resource-Aware Job Queueing
The `queueable` parameter enables intelligent resource-aware job queueing for heavy operations. When enabled:
1. Jobs are automatically queued when system resources (CPU, memory) are constrained
2. Queue capacity dynamically adjusts based on available resources
3. Queue position and status updates are sent via WebSocket
4. Jobs with high `resourceWeight` values have stricter queueing conditions
5. Long-running jobs don't block the system from handling other requests
### Resource Weight Guidelines
When setting the `resourceWeight` parameter, use these guidelines:
| Weight Range | Appropriate For |
|--------------|----------------|
| 1-20 | Lightweight operations: metadata reads, simple transforms, etc. |
| 21-50 | Medium operations: basic PDF manipulation, simple image operations |
| 51-80 | Heavy operations: PDF merging, image conversions, medium OCR |
| 81-100 | Very intensive operations: large OCR jobs, complex transformations |
### Example Queue Status Messages
```json
{
"jobId": "b4c9a31d-4b7e-42b2-8ab9-3cbe99d5b94f",
"status": "Queued",
"progress": 0,
"message": "Waiting in queue for resources (position 3)"
}
```
```json
{
"jobId": "b4c9a31d-4b7e-42b2-8ab9-3cbe99d5b94f",
"status": "Starting",
"progress": 10,
"message": "Resources available, starting job execution"
}
```
+56
View File
@@ -0,0 +1,56 @@
# Scan Upload Feature Improvements
## Code Improvements
1. **Modular JavaScript Architecture**
- Split the monolithic `scan-upload-direct.js` into logical modules:
- `logger.js`: Handles logging and status updates
- `peer-connection.js`: Manages WebRTC peer connections
- `camera.js`: Controls mobile camera functionality
- `scan-upload.js`: Main module that coordinates everything
2. **Separated CSS from HTML**
- Created dedicated CSS files:
- `scan-upload.css`: Styles for the desktop scan-upload page
- `mobile-scanner.css`: Styles for the mobile camera interface
3. **Improved Error Handling**
- Added better error handling throughout the codebase
- Improved user feedback for connection and camera issues
- Enhanced debug logging capabilities
4. **Backward Compatibility**
- Created a compatibility layer that maintains the old API
- Allows gradual migration to the new code structure
- Ensures existing integrations won't break
## UI Improvements
1. **Enhanced Responsive Design**
- Improved mobile layout with proper media queries
- Better handling of different screen sizes
2. **Better Visual Feedback**
- Clearer status messages for users
- Improved styling of the scan result display
- Enhanced debug information presentation
3. **Localization Support**
- Added proper Thymeleaf text references for all UI elements
- Uses the existing messages system for translations
## Security Improvements
1. **Enhanced WebRTC Implementation**
- Better handling of connection errors
- Improved security for peer connections
- Structured error handling for failed connections
## Next Steps
Potential future improvements:
1. Add more robust testing for the WebRTC functionality
2. Consider implementing a fallback method if WebRTC is not available
3. Add support for scanning multiple documents in one session
4. Implement better image quality control options
@@ -23,7 +23,10 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
"errorOAuth",
"file",
"messageType",
"infoMessage");
"infoMessage",
"async",
"session",
"t");
@Override
public boolean preHandle(
@@ -173,6 +173,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Other", "get-info-on-pdf");
addEndpointToGroup("Other", "show-javascript");
addEndpointToGroup("Other", "remove-image-pdf");
addEndpointToGroup("Other", "scan-upload");
// CLI
addEndpointToGroup("CLI", "compress-pdf");
@@ -17,7 +17,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -29,7 +29,7 @@ public class AnalysisController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/page-count", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/page-count", consumes = "multipart/form-data")
@Operation(
summary = "Get PDF page count",
description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO")
@@ -39,7 +39,7 @@ public class AnalysisController {
}
}
@PostMapping(value = "/basic-info", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/basic-info", consumes = "multipart/form-data")
@Operation(
summary = "Get basic PDF information",
description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO")
@@ -53,7 +53,7 @@ public class AnalysisController {
}
}
@PostMapping(value = "/document-properties", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/document-properties", consumes = "multipart/form-data")
@Operation(
summary = "Get PDF document properties",
description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO")
@@ -76,7 +76,7 @@ public class AnalysisController {
}
}
@PostMapping(value = "/page-dimensions", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/page-dimensions", consumes = "multipart/form-data")
@Operation(
summary = "Get page dimensions for all pages",
description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO")
@@ -96,7 +96,7 @@ public class AnalysisController {
}
}
@PostMapping(value = "/form-fields", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/form-fields", consumes = "multipart/form-data")
@Operation(
summary = "Get form field information",
description =
@@ -119,7 +119,7 @@ public class AnalysisController {
}
}
@PostMapping(value = "/annotation-info", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/annotation-info", consumes = "multipart/form-data")
@Operation(
summary = "Get annotation information",
description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO")
@@ -143,7 +143,7 @@ public class AnalysisController {
}
}
@PostMapping(value = "/font-info", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/font-info", consumes = "multipart/form-data")
@Operation(
summary = "Get font information",
description =
@@ -165,7 +165,7 @@ public class AnalysisController {
}
}
@PostMapping(value = "/security-info", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/security-info", consumes = "multipart/form-data")
@Operation(
summary = "Get security information",
description =
@@ -12,7 +12,7 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -33,7 +33,7 @@ public class CropController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/crop", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/crop", consumes = "multipart/form-data")
@Operation(
summary = "Crops a PDF document",
description =
@@ -28,6 +28,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.database.DatabaseService;
import stirling.software.common.annotations.AutoJobPostMapping;
@Slf4j
@Controller
@@ -5,7 +5,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mail.MailSendException;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -42,7 +42,7 @@ public class EmailController {
* attachment.
* @return ResponseEntity with success or error message.
*/
@PostMapping(consumes = "multipart/form-data", value = "/send-email")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/send-email")
@Operation(
summary = "Send an email with an attachment",
description =
@@ -20,7 +20,7 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -110,7 +110,7 @@ public class MergeController {
}
}
@PostMapping(consumes = "multipart/form-data", value = "/merge-pdfs")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/merge-pdfs")
@Operation(
summary = "Merge multiple PDF files into one",
description =
@@ -13,7 +13,7 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -36,7 +36,7 @@ public class MultiPageLayoutController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/multi-page-layout", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/multi-page-layout", consumes = "multipart/form-data")
@Operation(
summary = "Merge multiple pages of a PDF document into a single page",
description =
@@ -6,7 +6,7 @@ import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -46,7 +46,7 @@ public class PdfImageRemovalController {
* content type and filename.
* @throws IOException If an error occurs while processing the PDF file.
*/
@PostMapping(consumes = "multipart/form-data", value = "/remove-image-pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-image-pdf")
@Operation(
summary = "Remove images from file to reduce the file size.",
description =
@@ -15,7 +15,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -39,7 +39,7 @@ public class PdfOverlayController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data")
@Operation(
summary = "Overlay PDF files in various modes",
description =
@@ -9,7 +9,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -37,7 +37,7 @@ public class RearrangePagesPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-pages")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-pages")
@Operation(
summary = "Remove pages from a PDF file",
description =
@@ -236,7 +236,7 @@ public class RearrangePagesPDFController {
}
}
@PostMapping(consumes = "multipart/form-data", value = "/rearrange-pages")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/rearrange-pages")
@Operation(
summary = "Rearrange pages in a PDF file",
description =
@@ -7,7 +7,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -19,6 +19,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.general.RotatePDFRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@@ -30,7 +31,7 @@ public class RotationController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/rotate-pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/rotate-pdf")
@Operation(
summary = "Rotate a PDF file",
description =
@@ -14,7 +14,7 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -37,7 +37,7 @@ public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/scale-pages", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/scale-pages", consumes = "multipart/form-data")
@Operation(
summary = "Change the size of a PDF page/document",
description =
@@ -7,7 +7,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -31,7 +31,7 @@ public class SettingsController {
private final ApplicationProperties applicationProperties;
private final EndpointConfiguration endpointConfiguration;
@PostMapping("/update-enable-analytics")
@AutoJobPostMapping("/update-enable-analytics")
@Hidden
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
@@ -15,7 +15,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -40,7 +40,7 @@ public class SplitPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/split-pages")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/split-pages")
@Operation(
summary = "Split a PDF file into separate documents",
description =
@@ -15,7 +15,7 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -116,7 +116,7 @@ public class SplitPdfByChaptersController {
return bookmarks;
}
@PostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data")
@Operation(
summary = "Split PDFs by Chapters",
description = "Splits a PDF into chapters and returns a ZIP file.")
@@ -20,7 +20,7 @@ import org.apache.pdfbox.util.Matrix;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -43,7 +43,7 @@ public class SplitPdfBySectionsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data")
@Operation(
summary = "Split PDF pages into smaller sections",
description =
@@ -12,7 +12,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -38,7 +38,7 @@ public class SplitPdfBySizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
@Operation(
summary = "Auto split PDF pages into separate documents based on size or count",
description =
@@ -12,7 +12,7 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -33,7 +33,7 @@ public class ToSinglePageController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page")
@Operation(
summary = "Convert a multi-page PDF into a single long page PDF",
description =
@@ -36,6 +36,7 @@ import stirling.software.SPDF.model.AuthenticationType;
import stirling.software.SPDF.model.Role;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.model.api.user.UsernameAndPass;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
@@ -2,7 +2,7 @@ package stirling.software.SPDF.controller.api.converters;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -32,7 +32,7 @@ public class ConvertHtmlToPDF {
private final RuntimePathConfig runtimePathConfig;
@PostMapping(consumes = "multipart/form-data", value = "/html/pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf")
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description =
@@ -18,7 +18,7 @@ import org.apache.pdfbox.rendering.ImageType;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -49,7 +49,7 @@ public class ConvertImgPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf/img")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/img")
@Operation(
summary = "Convert PDF to image(s)",
description =
@@ -205,7 +205,7 @@ public class ConvertImgPDFController {
}
}
@PostMapping(consumes = "multipart/form-data", value = "/img/pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/img/pdf")
@Operation(
summary = "Convert images to a PDF file",
description =
@@ -12,7 +12,7 @@ import org.commonmark.renderer.html.AttributeProvider;
import org.commonmark.renderer.html.HtmlRenderer;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -41,7 +41,7 @@ public class ConvertMarkdownToPdf {
private final ApplicationProperties applicationProperties;
private final RuntimePathConfig runtimePathConfig;
@PostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
@Operation(
summary = "Convert a Markdown file to PDF",
description =
@@ -12,7 +12,7 @@ import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -84,7 +84,7 @@ public class ConvertOfficeController {
return fileExtension.matches(extensionPattern);
}
@PostMapping(consumes = "multipart/form-data", value = "/file/pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/file/pdf")
@Operation(
summary = "Convert a file to a PDF using LibreOffice",
description =
@@ -2,7 +2,7 @@ package stirling.software.SPDF.controller.api.converters;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -18,7 +18,7 @@ import stirling.software.common.util.PDFToFile;
@RequestMapping("/api/v1/convert")
public class ConvertPDFToHtml {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/html")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/html")
@Operation(
summary = "Convert PDF to HTML",
description =
@@ -7,7 +7,7 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -34,7 +34,7 @@ public class ConvertPDFToOffice {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf/presentation")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/presentation")
@Operation(
summary = "Convert PDF to Presentation format",
description =
@@ -49,7 +49,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
}
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text")
@Operation(
summary = "Convert PDF to Text or RTF format",
description =
@@ -77,7 +77,7 @@ public class ConvertPDFToOffice {
}
}
@PostMapping(consumes = "multipart/form-data", value = "/pdf/word")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/word")
@Operation(
summary = "Convert PDF to Word document",
description =
@@ -91,7 +91,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
}
@PostMapping(consumes = "multipart/form-data", value = "/pdf/xml")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/xml")
@Operation(
summary = "Convert PDF to XML",
description =
@@ -11,7 +11,7 @@ import org.apache.commons.io.FileUtils;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -33,7 +33,7 @@ import stirling.software.common.util.WebResponseUtils;
@Tag(name = "Convert", description = "Convert APIs")
public class ConvertPDFToPDFA {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa")
@Operation(
summary = "Convert a PDF to a PDF/A",
description =
@@ -9,7 +9,7 @@ import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -39,7 +39,7 @@ public class ConvertWebsiteToPDF {
private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties;
@PostMapping(consumes = "multipart/form-data", value = "/url/pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/url/pdf")
@Operation(
summary = "Convert a URL to a PDF",
description =
@@ -18,7 +18,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -46,7 +46,7 @@ public class ExtractCSVController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/pdf/csv", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/pdf/csv", consumes = "multipart/form-data")
@Operation(
summary = "Extracts a CSV document from a PDF",
description =
@@ -7,7 +7,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -36,7 +36,7 @@ public class FilterController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-text")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-text")
@Operation(
summary = "Checks if a PDF contains set text, returns true if does",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -54,7 +54,7 @@ public class FilterController {
}
// TODO
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-image")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-image")
@Operation(
summary = "Checks if a PDF contains an image",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -70,7 +70,7 @@ public class FilterController {
return null;
}
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-count")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-count")
@Operation(
summary = "Checks if a PDF is greater, less or equal to a setPageCount",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -103,7 +103,7 @@ public class FilterController {
return null;
}
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-size")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-size")
@Operation(
summary = "Checks if a PDF is of a certain size",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -146,7 +146,7 @@ public class FilterController {
return null;
}
@PostMapping(consumes = "multipart/form-data", value = "/filter-file-size")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-file-size")
@Operation(
summary = "Checks if a PDF is a set file size",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -179,7 +179,7 @@ public class FilterController {
return null;
}
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation")
@Operation(
summary = "Checks if a PDF is of a certain rotation",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -10,7 +10,7 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -38,7 +38,7 @@ public class AutoRenameController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/auto-rename")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/auto-rename")
@Operation(
summary = "Extract header from PDF file",
description =
@@ -19,7 +19,7 @@ import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -102,7 +102,7 @@ public class AutoSplitPdfController {
}
}
@PostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data")
@Operation(
summary = "Auto split PDF pages into separate documents",
description =
@@ -17,7 +17,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -69,7 +69,7 @@ public class BlankPageController {
return whitePixelPercentage >= whitePercent;
}
@PostMapping(consumes = "multipart/form-data", value = "/remove-blanks")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-blanks")
@Operation(
summary = "Remove blank pages from a PDF file",
description =
@@ -34,7 +34,7 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -654,7 +654,7 @@ public class CompressController {
};
}
@PostMapping(consumes = "multipart/form-data", value = "/compress-pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/compress-pdf")
@Operation(
summary = "Optimize PDF file",
description =
@@ -13,7 +13,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -37,7 +37,7 @@ public class DecompressPdfController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/decompress-pdf", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/decompress-pdf", consumes = "multipart/form-data")
@Operation(
summary = "Decompress PDF streams",
description = "Fully decompresses all PDF streams including text content")
@@ -19,7 +19,7 @@ import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -48,7 +48,7 @@ public class ExtractImageScansController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/extract-image-scans")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-image-scans")
@Operation(
summary = "Extract image scans from an input file",
description =
@@ -27,7 +27,7 @@ import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -53,7 +53,7 @@ public class ExtractImagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/extract-images")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-images")
@Operation(
summary = "Extract images from a PDF file",
description =
@@ -19,7 +19,7 @@ import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -47,7 +47,7 @@ public class FakeScanController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static final Random RANDOM = new Random();
@PostMapping(value = "/fake-scan", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/fake-scan", consumes = "multipart/form-data")
@Operation(
summary = "Convert PDF to look like a scanned document",
description =
@@ -13,7 +13,7 @@ import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -38,7 +38,7 @@ public class FlattenController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/flatten")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/flatten")
@Operation(
summary = "Flatten PDF form fields or full page",
description =
@@ -23,6 +23,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.MetadataRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.common.util.propertyeditor.StringToMapPropertyEditor;
@@ -51,7 +52,7 @@ public class MetadataController {
binder.registerCustomEditor(Map.class, "allRequestParams", new StringToMapPropertyEditor());
}
@PostMapping(consumes = "multipart/form-data", value = "/update-metadata")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/update-metadata")
@Operation(
summary = "Update metadata of a PDF file",
description =
@@ -18,7 +18,7 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -60,7 +60,7 @@ public class OCRController {
.toList();
}
@PostMapping(consumes = "multipart/form-data", value = "/ocr-pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/ocr-pdf")
@Operation(
summary = "Process PDF files with OCR using Tesseract",
description =
@@ -5,7 +5,7 @@ import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -31,7 +31,7 @@ public class OverlayImageController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/add-image")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-image")
@Operation(
summary = "Overlay image onto a PDF file",
description =
@@ -13,7 +13,7 @@ import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -37,7 +37,7 @@ public class PageNumbersController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/add-page-numbers", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/add-page-numbers", consumes = "multipart/form-data")
@Operation(
summary = "Add page numbers to a PDF document",
description =
@@ -36,7 +36,7 @@ import stirling.software.SPDF.model.api.misc.PrintFileRequest;
public class PrintFileController {
// TODO
// @PostMapping(value = "/print-file", consumes = "multipart/form-data")
// @AutoJobPostMapping(value = "/print-file", consumes = "multipart/form-data")
// @Operation(
// summary = "Prints PDF/Image file to a set printer",
// description =
@@ -8,7 +8,7 @@ import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -33,7 +33,7 @@ public class RepairController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/repair")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/repair")
@Operation(
summary = "Repair a PDF file",
description =
@@ -7,7 +7,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -27,7 +27,7 @@ public class ReplaceAndInvertColorController {
private final ReplaceAndInvertColorService replaceAndInvertColorService;
@PostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf")
@Operation(
summary = "Replace-Invert Color PDF",
description =
@@ -0,0 +1,24 @@
package stirling.software.SPDF.controller.api.misc;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
/**
* Controller for scan upload functionality - WebRTC version.
*
* This controller is completely empty as all functionality has been moved to WebRTC.
* The image transfer happens directly between browsers without any server involvement.
*/
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@Hidden
@Slf4j
public class ScanUploadController {
// All functionality has been moved to client-side WebRTC
}
@@ -9,7 +9,7 @@ import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -32,7 +32,7 @@ public class ShowJavascript {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/show-javascript")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/show-javascript")
@Operation(
summary = "Grabs all JS from a PDF and returns a single JS file with all code",
description = "desc. Input:PDF Output:JS Type:SISO")
@@ -27,7 +27,7 @@ import org.apache.pdfbox.util.Matrix;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -50,7 +50,7 @@ public class StampController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/add-stamp")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-stamp")
@Operation(
summary = "Add stamp to a PDF file",
description =
@@ -13,7 +13,7 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -39,7 +39,7 @@ public class UnlockPDFFormsController {
this.pdfDocumentFactory = pdfDocumentFactory;
}
@PostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms")
@Operation(
summary = "Remove read-only property from form fields",
description =
@@ -12,7 +12,7 @@ import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -46,7 +46,7 @@ public class PipelineController {
private final PostHogService postHogService;
@PostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request)
throws JsonMappingException, JsonProcessingException {
MultipartFile[] files = request.getFileInput();
@@ -59,7 +59,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -136,7 +136,7 @@ public class CertSignController {
}
}
@PostMapping(
@AutoJobPostMapping(
consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE
@@ -46,7 +46,7 @@ import org.apache.xmpbox.xml.XmpSerializer;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -118,7 +118,7 @@ public class GetInfoOnPDF {
return false;
}
@PostMapping(consumes = "multipart/form-data", value = "/get-info-on-pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/get-info-on-pdf")
@Operation(summary = "Summary here", description = "desc. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException {
MultipartFile inputFile = request.getFileInput();
@@ -7,7 +7,7 @@ import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -31,7 +31,7 @@ public class PasswordController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-password")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-password")
@Operation(
summary = "Remove password from a PDF file",
description =
@@ -50,7 +50,7 @@ public class PasswordController {
+ "_password_removed.pdf");
}
@PostMapping(consumes = "multipart/form-data", value = "/add-password")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-password")
@Operation(
summary = "Add password to a PDF file",
description =
@@ -18,7 +18,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -56,7 +56,7 @@ public class RedactController {
List.class, "redactions", new StringToArrayListPropertyEditor());
}
@PostMapping(value = "/redact", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/redact", consumes = "multipart/form-data")
@Operation(
summary = "Redacts areas and pages in a PDF document",
description =
@@ -190,7 +190,7 @@ public class RedactController {
return pageNumbers;
}
@PostMapping(value = "/auto-redact", consumes = "multipart/form-data")
@AutoJobPostMapping(value = "/auto-redact", consumes = "multipart/form-data")
@Operation(
summary = "Redacts listOfText in a PDF document",
description =
@@ -9,7 +9,7 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -32,7 +32,7 @@ public class RemoveCertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign")
@Operation(
summary = "Remove digital signature from PDF",
description =
@@ -14,7 +14,7 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -37,7 +37,7 @@ public class SanitizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf")
@Operation(
summary = "Sanitize a PDF file",
description =
@@ -27,7 +27,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -68,7 +68,7 @@ public class ValidateSignatureController {
description =
"Validates the digital signatures in a PDF file against default or custom"
+ " certificates. Input:PDF Output:JSON Type:SISO")
@PostMapping(value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<List<SignatureValidationResult>> validateSignature(
@ModelAttribute SignatureValidationRequest request) throws IOException {
List<SignatureValidationResult> results = new ArrayList<>();
@@ -28,7 +28,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -64,7 +64,7 @@ public class WatermarkController {
});
}
@PostMapping(consumes = "multipart/form-data", value = "/add-watermark")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-watermark")
@Operation(
summary = "Add watermark to a PDF file",
description =
@@ -177,7 +177,8 @@ public class WatermarkController {
}
if (!"".equals(resourceDir)) {
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
ClassPathResource classPathResource = new ClassPathResource(resourceDir, getClass().getClassLoader());
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile();
try (InputStream is = classPathResource.getInputStream();
@@ -0,0 +1,49 @@
package stirling.software.SPDF.controller.web;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Web controller for the scan-upload functionality.
*/
@Controller
@Tag(name = "Scan Upload", description = "Scan Upload Web Interface")
@Slf4j
@RequiredArgsConstructor
public class ScanUploadWebController {
/**
* Serves the scan-upload page for desktop/PC view.
*
* @param model the Spring MVC model
* @return the template name to render
*/
@GetMapping("/scan-upload")
@Hidden
public String scanUploadForm(Model model) {
model.addAttribute("currentPage", "scan-upload");
return "misc/scan-upload";
}
/**
* Serves the mobile page for camera capture and upload.
*
* @param model the Spring MVC model
* @param session the session ID parameter
* @return the template name to render
*/
@GetMapping("/mobile")
@Hidden
public String mobileView(Model model, @RequestParam(name = "session", required = false) String session) {
model.addAttribute("sessionId", session);
return "misc/mobile";
}
}
@@ -2,7 +2,7 @@ package stirling.software.SPDF.model.api.converters;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -18,7 +18,7 @@ import stirling.software.common.util.PDFToFile;
@RequestMapping("/api/v1/convert")
public class ConvertPDFToMarkdown {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/markdown")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/markdown")
@Operation(
summary = "Convert PDF to Markdown",
description =
+29 -2
View File
@@ -1549,7 +1549,7 @@ validateSignature.cert.bits=bits
####################
cookieBanner.popUp.title=How we use Cookies
cookieBanner.popUp.description.1=We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.
cookieBanner.popUp.description.2=If youd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly.
cookieBanner.popUp.description.2=If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly.
cookieBanner.popUp.acceptAllBtn=Okay
cookieBanner.popUp.acceptNecessaryBtn=No Thanks
cookieBanner.popUp.showPreferencesBtn=Manage preferences
@@ -1565,7 +1565,7 @@ cookieBanner.preferencesModal.description.2=Stirling PDF cannot—and will never
cookieBanner.preferencesModal.description.3=Your privacy and trust are at the core of what we do.
cookieBanner.preferencesModal.necessary.title.1=Strictly Necessary Cookies
cookieBanner.preferencesModal.necessary.title.2=Always Enabled
cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they cant be turned off.
cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off.
cookieBanner.preferencesModal.analytics.title=Analytics
cookieBanner.preferencesModal.analytics.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.
@@ -1604,3 +1604,30 @@ fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
#scan-upload
scan-upload.title=Scan and Upload
scan-upload.description=Scan a document or photo with your phone and send it directly to this browser.
scan-upload.instructions.title=Scan this QR code with your phone
scan-upload.instructions.text=Use your phone's camera to scan the QR code, then take a photo of your document.
scan-upload.session=Session ID:
scan-upload.result=Scan Result
scan-upload.download=Download
scan-upload.new-scan=New Scan
#mobile
mobile.title=Mobile Scanner
mobile.align-document=Align your document within the frame
mobile.retake=Retake
mobile.upload=Upload
mobile.uploading=Uploading...
mobile.success=Success!
mobile.success-message=Your scan has been successfully uploaded
mobile.camera-access=Camera Access Required
mobile.camera-permission=Please allow access to your camera to scan documents
mobile.grant-access=Grant Access
#home.scan-upload
home.scan-upload.title=Scan and Upload
home.scan-upload.desc=Scan a document with your phone and send it to your browser
scan-upload.tags=scan,upload,photo,camera,mobile
@@ -0,0 +1,415 @@
/* Mobile Scanner CSS */
body {
background: var(--md-sys-color-background, #000);
color: var(--md-sys-color-on-background, #fff);
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
height: 100vh; /* Fix height to prevent scrolling */
position: fixed; /* Prevent body scrolling */
width: 100%;
}
.app-header {
padding: 10px;
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.5);
}
.input-method-tabs {
display: flex;
border-radius: 8px;
overflow: hidden;
background: rgba(var(--md-sys-color-on-background, 255, 255, 255), 0.1);
margin-bottom: 10px;
}
.input-tab {
flex: 1;
padding: 12px;
text-align: center;
background: transparent;
border: none;
color: var(--md-sys-color-on-background, #fff);
font-weight: 500;
transition: background 0.3s;
cursor: pointer;
}
.input-tab.active {
background: var(--md-sys-color-primary, #4CAF50);
color: var(--md-sys-color-on-primary, #fff);
}
.file-upload-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 70vh;
padding: 20px;
}
.file-upload-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 180px;
height: 180px;
border: 2px dashed var(--md-sys-color-outline, rgba(var(--md-sys-color-on-background, 255, 255, 255), 0.5));
border-radius: 10px;
cursor: pointer;
transition: all 0.3s;
}
.file-upload-label:hover {
border-color: var(--md-sys-color-primary, #4CAF50);
background: rgba(255,255,255,0.05);
}
.upload-icon {
font-size: 48px;
color: var(--md-sys-color-primary, #4CAF50);
margin-bottom: 10px;
}
.upload-text {
color: var(--md-sys-color-on-background, #fff);
font-size: 16px;
}
.file-preview-container {
width: 100%;
max-width: 300px;
margin-top: 20px;
}
.file-preview {
width: 100%;
border-radius: 8px;
margin-bottom: 15px;
}
.file-actions {
display: flex;
justify-content: space-between;
gap: 8px;
}
.container, .review-container {
display: flex;
flex-direction: column;
height: calc(100vh - 50px); /* Account for header */
box-sizing: border-box;
padding: 10px;
overflow: hidden; /* Prevent container scrolling */
}
.camera-container {
flex: 1;
position: relative;
background: var(--md-sys-color-surface-container, #222);
border-radius: 10px;
overflow: hidden;
max-height: 70vh;
margin-bottom: 15px;
}
#camera-view {
width: 100%;
height: 100%;
object-fit: cover;
}
.controls, .review-controls {
position: fixed;
bottom: 20px;
left: 10px;
right: 10px;
display: flex;
justify-content: space-between;
z-index: 5;
}
.capture-btn, .review-btn {
border: none;
cursor: pointer;
font-weight: bold;
}
.capture-btn {
width: 70px;
height: 70px;
border-radius: 50%;
background: white;
position: relative;
border: 3px solid rgba(255,255,255,0.5);
margin: auto;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
}
.capture-btn::after {
content: '';
position: absolute;
top: 5px;
left: 5px;
width: 60px;
height: 60px;
background: white;
border-radius: 50%;
}
.review-container {
display: none;
padding-bottom: 80px; /* Make room for batch preview */
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.preview-header h3 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.preview-controls {
display: flex;
align-items: center;
gap: 10px;
}
.batch-btn {
background: var(--md-sys-color-secondary, #4285F4);
color: var(--md-sys-color-on-secondary, white);
border: none;
border-radius: 20px;
padding: 8px 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.batch-counter {
font-size: 14px;
color: var(--md-sys-color-on-background, white);
opacity: 0.8;
}
.batch-preview {
position: fixed;
bottom: 80px; /* Positioned above the camera controls */
left: 0;
right: 0;
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.8);
padding: 10px;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
z-index: 6; /* Below the controls (z-index: 10) */
}
.loading-message {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.7);
color: var(--md-sys-color-on-background, #fff);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
font-size: 18px;
}
.batch-preview.active {
max-height: 120px;
}
.batch-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.batch-actions {
display: flex;
gap: 8px;
}
.upload-batch-btn {
background: var(--md-sys-color-primary, #4CAF50);
color: var(--md-sys-color-on-primary, white);
border: none;
border-radius: 15px;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.batch-header h4 {
margin: 0;
font-size: 14px;
font-weight: normal;
}
.clear-btn {
background: var(--md-sys-color-error, #f44336);
color: var(--md-sys-color-on-error, white);
border: none;
border-radius: 15px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
}
.batch-thumbnails {
display: flex;
overflow-x: auto;
gap: 10px;
padding-bottom: 5px;
}
.batch-thumbnail {
position: relative;
width: 60px;
height: 60px;
flex-shrink: 0;
border-radius: 5px;
overflow: hidden;
}
.batch-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-thumbnail {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.6);
color: var(--md-sys-color-on-error, white);
border: none;
border-radius: 50%;
font-size: 10px;
line-height: 1;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
#capture-preview {
width: 100%;
object-fit: contain;
border-radius: 10px;
background: var(--md-sys-color-surface-container, #222);
max-height: 70vh;
flex: 1;
}
.retake-btn {
background: var(--md-sys-color-error, #f44336);
color: var(--md-sys-color-on-error, white);
border-radius: 25px;
padding: 12px 20px;
width: 45%;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
text-transform: uppercase;
font-weight: 500;
border: none;
}
.upload-btn {
background: var(--md-sys-color-primary, #4CAF50);
color: var(--md-sys-color-on-primary, white);
border-radius: 25px;
padding: 12px 20px;
width: 45%;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
text-transform: uppercase;
font-weight: 500;
border: none;
}
.spinner, .success-message, .camera-error {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
z-index: 1000;
text-align: center;
}
.spinner {
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.7);
}
.spinner-border {
border: 0.25em solid var(--md-sys-color-on-background, white);
border-right-color: transparent;
border-radius: 50%;
width: 3rem;
height: 3rem;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.success-message {
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.7);
color: var(--md-sys-color-on-background, white);
}
.success-message i {
font-size: 60px;
color: var(--md-sys-color-primary, #4CAF50);
margin-bottom: 20px;
}
.camera-error {
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.9);
color: var(--md-sys-color-on-background, white);
padding: 20px;
}
/* Add more polished styling */
.spinner-text {
margin-top: 15px;
font-size: 16px;
font-weight: 500;
}
.success-message h2 {
font-weight: 500;
margin-top: 0;
}
.success-message p {
font-size: 16px;
opacity: 0.9;
}
@@ -0,0 +1,119 @@
/* Scan Upload CSS */
.scan-container {
margin: 20px auto;
max-width: 800px;
}
.card-header h2 {
color: var(--md-sys-color-on-surface, #000);
}
.card-body {
color: var(--md-sys-color-on-surface, #000);
}
#qrcode-container, .scan-result-container {
margin: 20px;
text-align: center;
}
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.image-card {
border-radius: 8px;
overflow: hidden;
background: var(--md-sys-color-surface-container, #fff);
box-shadow: 0 2px 5px rgba(var(--md-elevation-shadow-color-rgb), 0.2);
transition: transform 0.2s ease;
}
.image-card:hover {
transform: translateY(-3px);
}
.gallery-image {
width: 100%;
height: 150px;
object-fit: cover;
display: block;
}
.image-actions {
padding: 10px;
display: flex;
justify-content: space-between;
}
.image-action-btn {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
}
.session-id, .status-msg {
margin-top: 10px;
font-family: monospace;
background: var(--md-sys-color-surface-container-low, #f8f9fa);
color: var(--md-sys-color-on-surface, #000);
padding: 8px;
border-radius: 4px;
display: inline-block;
}
.actions {
margin-top: 15px;
display: flex;
justify-content: center;
gap: 10px;
}
.scan-info {
background-color: var(--md-sys-color-surface-variant, #f0f0f0);
border-left: 4px solid var(--md-sys-color-primary, #0d6efd);
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
color: var(--md-sys-color-on-surface-variant, #000);
}
.alert-info {
color: var(--md-sys-color-on-surface-variant, #000);
}
/* Ensure download button has proper colors */
#download-btn {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-color: var(--md-sys-color-primary);
}
#new-scan-btn {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
border-color: var(--md-sys-color-secondary);
}
@media (max-width: 768px) {
.scan-container {
margin: 10px;
}
#qrcode-container, .scan-result-container {
margin: 10px;
}
.actions {
flex-direction: column;
align-items: center;
}
.actions .btn {
width: 100%;
margin-bottom: 10px;
}
}
@@ -16,8 +16,10 @@ function setLanguageForDropdown(dropdownClass) {
function updateUrlWithLanguage(languageCode) {
const currentURL = new URL(window.location.href);
currentURL.searchParams.set('lang', languageCode);
window.location.href = currentURL.toString();
if (currentURL.searchParams.get('lang') !== languageCode) {
currentURL.searchParams.set('lang', languageCode);
window.location.href = currentURL.toString();
}
}
function handleDropdownItemClick(event) {
@@ -32,15 +34,25 @@ function handleDropdownItemClick(event) {
}
function checkUserLanguage(defaultLocale) {
const currentLanguageInDOM = document.documentElement.getAttribute('data-language');
const currentURL = new URL(window.location.href);
const langParam = currentURL.searchParams.get('lang');
if (
!localStorage.getItem('languageCode') ||
document.documentElement.getAttribute('data-language') != defaultLocale
currentLanguageInDOM !== defaultLocale ||
langParam !== defaultLocale
) {
localStorage.setItem('languageCode', defaultLocale);
updateUrlWithLanguage(defaultLocale);
if (langParam !== defaultLocale) {
currentURL.searchParams.set('lang', defaultLocale);
window.location.href = currentURL.toString();
}
}
}
function initLanguageSettings() {
document.addEventListener('DOMContentLoaded', function () {
setLanguageForDropdown('.lang_dropdown-item');
@@ -0,0 +1,65 @@
/**
* Backward compatibility script for scan-upload-direct.js
* This loads the modular version
*/
// Add async script loading
function loadScript(src, async = true) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = async;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Load the modules asynchronously
async function loadModules() {
try {
console.info('Loading scan-upload modules via compatibility layer...');
// If using type="module" is not supported, load individual scripts
if (typeof window.initScanUploadPC !== 'function' &&
typeof window.initScanUploadMobile !== 'function') {
// Load the necessary scripts
await loadScript('/js/scan-upload/logger.js', true);
await loadScript('/js/scan-upload/peer-connection.js', true);
await loadScript('/js/scan-upload/camera.js', true);
await loadScript('/js/scan-upload/scan-upload.js', true);
console.info('Modules loaded via compatibility layer');
}
} catch (error) {
console.error('Error loading modules:', error);
alert('Failed to load scan-upload modules. Please try refreshing the page.');
}
}
// Load scripts on page load
document.addEventListener('DOMContentLoaded', loadModules);
// Forward function calls to the module implementations
window.initScanUploadPC = function() {
if (typeof window.initScanUploadPC === 'function') {
return window.initScanUploadPC();
} else {
console.error('ScanUpload module not loaded correctly');
}
};
window.initScanUploadMobile = function() {
if (typeof window.initScanUploadMobile === 'function') {
return window.initScanUploadMobile();
} else {
console.error('ScanUpload module not loaded correctly');
}
};
// Backward compatibility stubs
window.generateRandomId = function() { return 'xxxx-xxxx-xxxx-xxxx'.replace(/[x]/g, () => (Math.random() * 16 | 0).toString(16)); };
window.updateStatus = function(message) { console.log(message); };
window.showError = function(message) { alert("Upload Error: " + message); };
window.logDebug = function(message) { console.log("LOG:", message); };
@@ -0,0 +1,496 @@
/**
* Camera handling module for the mobile scanner
*/
import Logger from './logger.js';
import PeerConnection from './peer-connection.js';
const Camera = {
stream: null,
capturedImageData: null,
capturedImages: [],
/**
* Initialize the camera and file upload on mobile device
*/
init: function() {
// Initialize camera
this.initCamera();
// Set up tab switching
this.setupTabs();
// Set up file upload handling
this.setupFileUpload();
},
/**
* Initialize the camera
*/
initCamera: function() {
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
.then(stream => {
this.stream = stream;
// Camera stream started
document.getElementById('camera-view').srcObject = stream;
this.setupCameraControls();
})
.catch((err) => {
console.error("Camera error:", err);
document.getElementById('camera-error').style.display = 'flex';
Logger.showError("Camera access denied or unavailable");
// Automatically switch to file upload if camera fails
this.switchToFileUpload();
});
},
/**
* Set up tab switching between camera and file upload
*/
setupTabs: function() {
const cameraTab = document.getElementById('camera-tab');
const fileTab = document.getElementById('file-tab');
if (cameraTab && fileTab) {
cameraTab.addEventListener('click', () => this.switchToCamera());
fileTab.addEventListener('click', () => this.switchToFileUpload());
}
},
/**
* Switch to camera view
*/
switchToCamera: function() {
document.getElementById('camera-tab').classList.add('active');
document.getElementById('file-tab').classList.remove('active');
document.getElementById('camera-container').style.display = 'flex';
document.getElementById('file-container').style.display = 'none';
},
/**
* Switch to file upload view
*/
switchToFileUpload: function() {
document.getElementById('file-tab').classList.add('active');
document.getElementById('camera-tab').classList.remove('active');
document.getElementById('file-container').style.display = 'flex';
document.getElementById('camera-container').style.display = 'none';
},
/**
* Set up event listeners for camera controls
*/
setupCameraControls: function() {
const captureBtn = document.getElementById('capture-button');
if (captureBtn) {
captureBtn.onclick = () => this.captureImage();
}
const uploadBtn = document.getElementById('upload-button');
if (uploadBtn) {
uploadBtn.onclick = () => this.uploadImage();
} else {
console.warn('Upload button not found');
}
const retakeBtn = document.getElementById('retake-button');
if (retakeBtn) {
retakeBtn.onclick = () => this.retakeImage();
}
// Add batch-related controls
const addToBatchBtn = document.getElementById('add-to-batch-btn');
if (addToBatchBtn) {
addToBatchBtn.onclick = () => this.addToBatch();
}
const uploadBatchBtn = document.getElementById('upload-batch-btn');
if (uploadBatchBtn) {
uploadBatchBtn.onclick = () => this.uploadBatch();
}
const clearBatchBtn = document.getElementById('clear-batch-btn');
if (clearBatchBtn) {
clearBatchBtn.onclick = () => this.clearBatch();
}
},
/**
* Set up file upload functionality
*/
setupFileUpload: function() {
const fileInput = document.getElementById('file-input');
const filePreview = document.getElementById('file-preview');
const filePreviewContainer = document.getElementById('file-preview-container');
const cancelFileBtn = document.getElementById('cancel-file-btn');
const uploadFileBtn = document.getElementById('upload-file-btn');
const addFileToBatchBtn = document.getElementById('add-file-to-batch-btn');
if (fileInput) {
fileInput.onchange = (e) => {
const files = e.target.files;
if (files.length > 0) {
// Handle multiple files if supported
if (files.length === 1) {
// Single file - show preview
const file = files[0];
const reader = new FileReader();
reader.onload = (e) => {
filePreview.src = e.target.result;
this.capturedImageData = e.target.result;
filePreviewContainer.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
// Multiple files - add all to batch
this.addFilesToBatch(files);
fileInput.value = ''; // Reset input
}
}
};
}
if (cancelFileBtn) {
cancelFileBtn.onclick = () => {
fileInput.value = '';
filePreview.src = '';
this.capturedImageData = null;
filePreviewContainer.style.display = 'none';
};
}
if (uploadFileBtn) {
uploadFileBtn.onclick = () => {
if (this.capturedImageData) {
this.uploadImage();
} else {
Logger.showError("No image selected. Please select an image first.");
}
};
}
if (addFileToBatchBtn) {
addFileToBatchBtn.onclick = () => {
if (this.capturedImageData) {
// Add current file to batch
this.addFileToBatch();
} else {
Logger.showError("No image selected. Please select an image first.");
}
};
}
},
/**
* Add current file to batch
*/
addFileToBatch: function() {
if (!this.capturedImageData) {
Logger.showError("No image selected. Please select an image first.");
return;
}
// Add current image to the batch
this.capturedImages.push(this.capturedImageData);
// Update batch counter
this.updateBatchCounter();
// Add thumbnail to batch preview
this.addThumbnail(this.capturedImageData);
// Show batch preview if it's the first image
if (this.capturedImages.length === 1) {
document.getElementById('batch-preview').classList.add('active');
}
// Reset file input and preview
const fileInput = document.getElementById('file-input');
const filePreview = document.getElementById('file-preview');
const filePreviewContainer = document.getElementById('file-preview-container');
if (fileInput) fileInput.value = '';
if (filePreview) filePreview.src = '';
if (filePreviewContainer) filePreviewContainer.style.display = 'none';
this.capturedImageData = null;
},
/**
* Add multiple files to batch
* @param {FileList} files - List of files to add
*/
addFilesToBatch: function(files) {
if (!files || files.length === 0) return;
// Show loading indicator
const loadingMessage = document.createElement('div');
loadingMessage.className = 'loading-message';
loadingMessage.textContent = 'Processing files...';
document.body.appendChild(loadingMessage);
// Process each file
let processed = 0;
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = (e) => {
// Add to batch
this.capturedImages.push(e.target.result);
this.addThumbnail(e.target.result);
processed++;
if (processed === files.length) {
// All files processed
document.body.removeChild(loadingMessage);
// Update counter and show batch
this.updateBatchCounter();
document.getElementById('batch-preview').classList.add('active');
}
};
reader.readAsDataURL(file);
});
},
/**
* Capture an image from the camera
*/
captureImage: function() {
// Capture button clicked
const canvas = document.createElement('canvas');
const video = document.getElementById('camera-view');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
this.capturedImageData = canvas.toDataURL('image/jpeg', 0.9);
document.getElementById('capture-preview').src = this.capturedImageData;
document.querySelector('.container').style.display = 'none';
document.getElementById('review-container').style.display = 'flex';
// Image captured
},
/**
* Add the current image to the batch
*/
addToBatch: function() {
if (!this.capturedImageData) {
Logger.showError("No image captured. Please take a picture first.");
return;
}
// Add current image to the batch
this.capturedImages.push(this.capturedImageData);
// Update batch counter
this.updateBatchCounter();
// Add thumbnail to batch preview
this.addThumbnail(this.capturedImageData);
// Show batch preview if it's the first image
if (this.capturedImages.length === 1) {
document.getElementById('batch-preview').classList.add('active');
}
// Return to camera view
this.retakeImage();
},
/**
* Update the batch counter
*/
updateBatchCounter: function() {
const counter = document.getElementById('batch-counter');
const count = this.capturedImages.length;
counter.textContent = count + (count === 1 ? ' image' : ' images');
},
/**
* Add a thumbnail to the batch preview
* @param {string} dataUrl - Image data URL
*/
addThumbnail: function(dataUrl) {
const container = document.getElementById('batch-thumbnails');
const index = this.capturedImages.length - 1;
const thumbnail = document.createElement('div');
thumbnail.className = 'batch-thumbnail';
thumbnail.dataset.index = index;
const img = document.createElement('img');
img.src = dataUrl;
img.alt = 'Thumbnail';
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-thumbnail';
removeBtn.innerHTML = '×';
removeBtn.onclick = (e) => {
e.stopPropagation();
this.removeFromBatch(index);
};
thumbnail.appendChild(img);
thumbnail.appendChild(removeBtn);
container.appendChild(thumbnail);
},
/**
* Remove an image from the batch
* @param {number} index - Index of the image to remove
*/
removeFromBatch: function(index) {
// Remove from array
this.capturedImages.splice(index, 1);
// Update batch counter
this.updateBatchCounter();
// Refresh thumbnails
this.refreshThumbnails();
// Hide batch preview if no images left
if (this.capturedImages.length === 0) {
document.getElementById('batch-preview').classList.remove('active');
}
},
/**
* Clear all images from the batch
*/
clearBatch: function() {
this.capturedImages = [];
this.updateBatchCounter();
document.getElementById('batch-thumbnails').innerHTML = '';
document.getElementById('batch-preview').classList.remove('active');
},
/**
* Refresh all thumbnails in the batch preview
*/
refreshThumbnails: function() {
const container = document.getElementById('batch-thumbnails');
container.innerHTML = '';
this.capturedImages.forEach((dataUrl, index) => {
this.addThumbnail(dataUrl);
});
},
/**
* Upload all captured images
*/
uploadImage: function() {
// Check if we have images in the batch or just the current preview
if (this.capturedImages.length > 0) {
// Upload all images in the batch
this.uploadBatch();
} else if (this.capturedImageData) {
// Upload just the current image
PeerConnection.sendImage(this.capturedImageData);
} else {
Logger.showError("No images captured. Please take pictures first.");
}
},
/**
* Upload all images in the batch
*/
uploadBatch: function() {
if (this.capturedImages.length === 0) {
Logger.showError("Batch is empty. Please add images first.");
return;
}
// Show spinner
document.getElementById('spinner').style.display = 'flex';
// Send each image sequentially
const totalImages = this.capturedImages.length;
let uploadedCount = 0;
const sendNextImage = (index) => {
if (index >= this.capturedImages.length) {
// All images sent, send completion notification
PeerConnection.connection.send({ type: 'batch-complete' });
// Hide spinner and show success message
setTimeout(() => {
document.getElementById('spinner').style.display = 'none';
document.getElementById('success-message').style.display = 'flex';
// Close window after success
setTimeout(() => window.close(), 3000);
}, 1000);
return;
}
// Update spinner text
const spinnerText = document.querySelector('.spinner-text');
spinnerText.textContent = `Uploading ${index + 1}/${totalImages}`;
// Send image
const dataUrl = this.capturedImages[index];
try {
// Using the peer connection directly for more control
if (PeerConnection.connection && PeerConnection.connection.open) {
PeerConnection.connection.send({ type: 'scan-image', data: dataUrl });
// Wait a bit before sending next image to prevent overwhelming the connection
setTimeout(() => sendNextImage(index + 1), 500);
} else {
// Connection not ready yet, set up connection first
PeerConnection.connection = PeerConnection.peer.connect(PeerConnection.sessionId, { reliable: true });
PeerConnection.connection.on('open', () => {
PeerConnection.connection.send({ type: 'scan-image', data: dataUrl });
setTimeout(() => sendNextImage(index + 1), 500);
});
PeerConnection.connection.on('error', (err) => {
console.error("Connection error:", err);
Logger.showError("Upload failed: " + (err.message || "unknown error"));
// Try to continue with remaining images
setTimeout(() => sendNextImage(index + 1), 1000);
});
}
} catch (err) {
console.error("Send error:", err);
Logger.showError("Failed to send image: " + (err.message || "unknown error"));
// Try to continue with remaining images
setTimeout(() => sendNextImage(index + 1), 1000);
}
};
// Start sending images
sendNextImage(0);
},
/**
* Switch back to camera view for retaking the image
*/
retakeImage: function() {
document.querySelector('.container').style.display = 'flex';
document.getElementById('review-container').style.display = 'none';
// Retake selected
},
/**
* Stop the camera stream
*/
stop: function() {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
}
};
export default Camera;
@@ -0,0 +1,79 @@
/**
* Utility functions for handling images in the scan upload feature
*/
const ImageUtils = {
/**
* Download all images as a ZIP file
* @param {NodeList} images - Collection of image elements
*/
downloadAllAsZip: function(images) {
if (!images || images.length === 0) {
console.warn('No images to download');
return;
}
// Load JSZip dynamically
const script = document.createElement('script');
script.src = '/js/thirdParty/jszip.min.js';
script.onload = () => {
this.createZip(images);
};
script.onerror = () => {
console.error('Failed to load JSZip');
alert('Failed to load ZIP library. Please try downloading images individually.');
};
document.head.appendChild(script);
},
/**
* Create ZIP file with images
* @param {NodeList} images - Collection of image elements
*/
createZip: function(images) {
try {
// JSZip is now loaded globally
const zip = new JSZip();
const timestamp = new Date().toISOString().slice(0, 10);
// Add each image to the zip
for (let i = 0; i < images.length; i++) {
const img = images[i];
// Extract base64 data from data URL
const dataUrl = img.src;
const base64Data = dataUrl.split(',')[1];
// Add to zip
zip.file(`scan-${i+1}.jpg`, base64Data, {base64: true});
}
// Generate the zip file
zip.generateAsync({type: 'blob'}).then((blob) => {
// Create download link
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `scans-${timestamp}.zip`;
// Trigger download
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
} catch (error) {
console.error('Error creating ZIP file:', error);
alert('Failed to create ZIP file. Please try downloading images individually.');
}
},
/**
* Convert a data URL to a Blob
* @param {string} url - Data URL
* @returns {Promise<Blob>} - Promise resolving to a Blob
*/
urlToBlob: function(url) {
return fetch(url).then(response => response.blob());
}
};
export default ImageUtils;
@@ -0,0 +1,39 @@
/**
* Simplified logger module for the scan-upload functionality
*/
const Logger = {
/**
* Log a debug message to the console (disabled in production)
* @param {string} message - Message to log
*/
debug: function(message) {
// Debug logging disabled
},
/**
* Update status message in the UI
* @param {string} message - Status message to display
*/
updateStatus: function(message) {
const el = document.getElementById('status-message');
if (el) {
el.textContent = message;
el.style.display = message ? 'block' : 'none';
}
},
/**
* Show an error message
* @param {string} message - Error message to display
*/
showError: function(message) {
this.updateStatus("Error: " + message);
// Only show alerts for critical errors
if (message.includes('failed') || message.includes('denied')) {
alert("Upload Error: " + message);
}
}
};
export default Logger;

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