merge: pull latest main into SaaS

This commit is contained in:
Anthony Stirling
2026-06-08 16:28:14 +01:00
94 changed files with 3798 additions and 430 deletions
@@ -1,11 +1,21 @@
package stirling.software.common.service; package stirling.software.common.service;
import java.util.List;
/** Provides metadata about tool endpoints for internal dispatch. */ /** Provides metadata about tool endpoints for internal dispatch. */
public interface ToolMetadataService { public interface ToolMetadataService {
/** Returns true if the given operation path accepts multiple input files. */ /** Returns true if the given operation path accepts multiple input files. */
boolean isMultiInput(String operationPath); boolean isMultiInput(String operationPath);
/**
* Returns the file extensions (lowercase, no leading dot, e.g. {@code "pdf"}) that the
* operation accepts as input ({@code output=false}) or produces as output ({@code
* output=true}), derived from the endpoint's declared type. Returns {@code null} when the
* endpoint declares no specific type, which callers should treat as "any type accepted".
*/
List<String> getExtensionTypes(boolean output, String operationPath);
/** /**
* Returns true when the endpoint's ZIP response is a transport for multiple typed results and * Returns true when the endpoint's ZIP response is a transport for multiple typed results and
* should be unpacked: multi-output endpoints (Type:SIMO / Type:MIMO) and wrapper declarations * should be unpacked: multi-output endpoints (Type:SIMO / Type:MIMO) and wrapper declarations
@@ -25,6 +25,7 @@ import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.SvgSanitizer;
import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -36,6 +37,7 @@ public class OverlayImageController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
private final SvgSanitizer svgSanitizer;
@AutoJobPostMapping( @AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
@@ -61,6 +63,9 @@ public class OverlayImageController {
byte[] imageBytes = imageFile.getBytes(); byte[] imageBytes = imageFile.getBytes();
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes); boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
if (isSvg) {
imageBytes = svgSanitizer.sanitize(imageBytes);
}
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) { try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
int pages = document.getNumberOfPages(); int pages = document.getNumberOfPages();
@@ -63,6 +63,7 @@ public class ApiDocService implements stirling.software.common.service.ToolMetad
return "http://localhost:" + port + contextPath + "/v1/api-docs"; return "http://localhost:" + port + contextPath + "/v1/api-docs";
} }
@Override
public List<String> getExtensionTypes(boolean output, String operationName) { public List<String> getExtensionTypes(boolean output, String operationName) {
if (outputToFileTypes.isEmpty()) { if (outputToFileTypes.isEmpty()) {
outputToFileTypes.put("PDF", List.of("pdf")); outputToFileTypes.put("PDF", List.of("pdf"));
@@ -10,6 +10,7 @@ import org.apache.batik.bridge.GVTBuilder;
import org.apache.batik.bridge.UserAgent; import org.apache.batik.bridge.UserAgent;
import org.apache.batik.bridge.UserAgentAdapter; import org.apache.batik.bridge.UserAgentAdapter;
import org.apache.batik.gvt.GraphicsNode; import org.apache.batik.gvt.GraphicsNode;
import org.apache.batik.util.ParsedURL;
import org.apache.batik.util.XMLResourceDescriptor; import org.apache.batik.util.XMLResourceDescriptor;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPage;
@@ -39,7 +40,16 @@ public class SvgOverlayUtil {
svgDoc = factory.createSVGDocument("file:///overlay.svg", inputStream); svgDoc = factory.createSVGDocument("file:///overlay.svg", inputStream);
} }
UserAgent userAgent = new UserAgentAdapter(); UserAgent userAgent =
new UserAgentAdapter() {
@Override
public void checkLoadExternalResource(
ParsedURL resourceURL, ParsedURL docURL) {
throw new SecurityException(
"External resource loading is disabled for SVG overlays: "
+ resourceURL);
}
};
DocumentLoader loader = new DocumentLoader(userAgent); DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader); BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC); ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -93,6 +93,11 @@ posthog.host=https://eu.i.posthog.com
spring.main.allow-bean-definition-overriding=true spring.main.allow-bean-definition-overriding=true
# spring-data-redis is on the classpath only for the optional Valkey backplane (which wires its own
# factory); exclude Spring Boot's stock Redis auto-config so a default install doesn't create a dead
# localhost:6379 factory that flips /actuator/health to DOWN.
spring.autoconfigure.exclude=org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration,org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration
# Set up a consistent temporary directory location # Set up a consistent temporary directory location
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
@@ -32,6 +32,7 @@ import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.misc.OverlayImageRequest; import stirling.software.SPDF.model.api.misc.OverlayImageRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.SvgSanitizer;
import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -52,6 +53,7 @@ class OverlayImageControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory; @Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager; @Mock private TempFileManager tempFileManager;
@Mock private SvgSanitizer svgSanitizer;
@InjectMocks private OverlayImageController controller; @InjectMocks private OverlayImageController controller;
@@ -205,6 +207,52 @@ class OverlayImageControllerTest {
mockDoc.close(); mockDoc.close();
} }
@Test
void overlayImage_svgInput_sanitizedBeforeOverlay() throws Exception {
byte[] maliciousSvg =
("<svg xmlns=\"http://www.w3.org/2000/svg\""
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\""
+ " width=\"10\" height=\"10\">"
+ "<image x=\"0\" y=\"0\" width=\"10\" height=\"10\""
+ " xlink:href=\"file:///etc/passwd\"/>"
+ "</svg>")
.getBytes();
byte[] sanitized =
("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\">"
+ "<image x=\"0\" y=\"0\" width=\"10\" height=\"10\"/>"
+ "</svg>")
.getBytes();
when(svgSanitizer.sanitize(maliciousSvg)).thenReturn(sanitized);
MockMultipartFile svgFile =
new MockMultipartFile("imageFile", "overlay.svg", "image/svg+xml", maliciousSvg);
OverlayImageRequest request = new OverlayImageRequest();
request.setFileInput(pdfFile);
request.setImageFile(svgFile);
request.setX(0);
request.setY(0);
request.setEveryPage(false);
PDDocument mockDoc = new PDDocument();
mockDoc.addPage(new PDPage(PDRectangle.A4));
when(pdfDocumentFactory.load(any(byte[].class))).thenReturn(mockDoc);
try (MockedStatic<WebResponseUtils> mockedWebResponse =
mockStatic(WebResponseUtils.class)) {
mockedWebResponse
.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(streamingOk("result".getBytes()));
controller.overlayImage(request);
}
mockDoc.close();
verify(svgSanitizer).sanitize(maliciousSvg);
}
@Test @Test
void overlayImage_withCoordinates_usesXY() throws Exception { void overlayImage_withCoordinates_usesXY() throws Exception {
OverlayImageRequest request = new OverlayImageRequest(); OverlayImageRequest request = new OverlayImageRequest();
@@ -0,0 +1,319 @@
package stirling.software.proprietary.policy.controller;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.ManualTrigger;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Manages policies and runs pipelines. The premium backend entry point: CRUD for stored {@code
* Policy} objects, running a stored policy by id, and running an ad-hoc pipeline (for AI/Automate
* one-offs).
*
* <p>Runs execute asynchronously and return a run id immediately. Poll {@code GET /run/{runId}} for
* status, and download outputs via the existing {@code GET /api/v1/general/files/{fileId}} using
* the file ids in the run view.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/policies")
@Hidden
@PremiumEndpoint
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
public class PolicyController {
private final ManualTrigger manualTrigger;
private final PolicyRunRegistry runRegistry;
private final PolicyStore policyStore;
private final ObjectMapper objectMapper;
private final TempFileManager tempFileManager;
/** SSE emitter timeout, generous enough for long multi-step runs on large files. */
@Value("${stirling.policies.streamTimeoutMs:1800000}")
private long streamTimeoutMs;
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Run a tool pipeline",
description =
"Accepts the documents to process (multipart field 'fileInput'), any supporting"
+ " files (each under a multipart field named as its asset key, e.g."
+ " 'company-logo'), and a JSON pipeline definition ('json'). Runs the"
+ " steps in order asynchronously and returns a run id. Poll the run"
+ " status endpoint and download outputs via /api/v1/general/files/{id}.")
public ResponseEntity<JobResponse<Void>> run(
@RequestParam("json") String json, MultipartHttpServletRequest request)
throws IOException {
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
String runId = manualTrigger.fire(definition, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
@PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Run a tool pipeline with live progress",
description =
"Same as /run, but returns Server-Sent Events: a 'step' event as each step"
+ " starts and completes, then a terminal 'completed', 'failed',"
+ " 'cancelled', or 'waiting' event carrying the final run view.")
public SseEmitter runStream(
@RequestParam("json") String json, MultipartHttpServletRequest request)
throws IOException {
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
PolicyRunHandle handle = manualTrigger.fire(definition, inputs, streamListener(emitter));
// Close the stream with a terminal event once the run finishes. whenComplete runs on the
// engine's worker thread after the run is done, so this never races the step events.
handle.completion()
.whenComplete(
(run, throwable) -> {
if (throwable != null) {
sendEvent(
emitter,
"failed",
Map.of("message", throwable.getMessage()));
} else {
sendEvent(emitter, terminalEventName(run), PolicyRunView.of(run));
}
emitter.complete();
});
return emitter;
}
@GetMapping("/run/{runId}")
@Operation(
summary = "Get pipeline run status",
description = "Returns the current status, step cursor, and output files of a run.")
public ResponseEntity<PolicyRunView> status(@PathVariable String runId) {
PolicyRun run = runRegistry.get(runId);
if (run == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(PolicyRunView.of(run));
}
// --- Policy management ---
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Create or update a policy",
description =
"Stores a policy (trigger config + steps + output + metadata). A blank id is"
+ " assigned; returns the stored policy with its id.")
public ResponseEntity<Policy> savePolicy(@RequestBody String json) {
return ResponseEntity.ok(policyStore.save(parsePolicy(json)));
}
@GetMapping
@Operation(summary = "List policies")
public List<Policy> listPolicies() {
return policyStore.all();
}
@GetMapping("/{policyId}")
@Operation(summary = "Get a policy by id")
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
return policyStore
.get(policyId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@DeleteMapping("/{policyId}")
@Operation(summary = "Delete a policy by id")
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
return policyStore.delete(policyId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Run a stored policy",
description =
"Runs the stored policy's pipeline on the supplied files (primary documents"
+ " under 'fileInput', supporting files under their asset-key fields)."
+ " Runs regardless of the policy's enabled flag, which only gates"
+ " automatic triggering. Returns a run id.")
public ResponseEntity<JobResponse<Void>> runStoredPolicy(
@PathVariable String policyId, MultipartHttpServletRequest request) throws IOException {
Policy policy =
policyStore
.get(policyId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "No policy: " + policyId));
PolicyInputs inputs = collectInputs(request);
String runId = manualTrigger.run(policy, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
private Policy parsePolicy(String json) {
try {
return objectMapper.readValue(json, Policy.class);
} catch (JacksonException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON");
}
}
private PipelineDefinition parseDefinition(String json) {
PipelineDefinition definition;
try {
definition = objectMapper.readValue(json, PipelineDefinition.class);
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON");
}
if (definition.steps().isEmpty()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Pipeline definition has no steps");
}
return definition;
}
/**
* Split the multipart file parts into the primary document stream ("fileInput") and the named
* supporting-file store: every other file field becomes an asset keyed by its field name, which
* a step references from {@code fileParameters}.
*/
private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException {
MultiValueMap<String, MultipartFile> fileMap = request.getMultiFileMap();
List<Resource> primary = toResources(fileMap.get("fileInput"));
Map<String, List<Resource>> supportingFiles = new LinkedHashMap<>();
for (Map.Entry<String, List<MultipartFile>> entry : fileMap.entrySet()) {
if ("fileInput".equals(entry.getKey())) {
continue;
}
List<Resource> assets = toResources(entry.getValue());
if (!assets.isEmpty()) {
supportingFiles.put(entry.getKey(), assets);
}
}
return new PolicyInputs(primary, supportingFiles);
}
/**
* A progress listener that forwards each step transition to the SSE stream as a "step" event.
*/
private PolicyProgressListener streamListener(SseEmitter emitter) {
return new PolicyProgressListener() {
@Override
public void onStepStart(int stepIndex, int stepCount, String operation) {
sendEvent(emitter, "step", stepEvent("started", stepIndex, stepCount, operation));
}
@Override
public void onStepComplete(int stepIndex, int stepCount, String operation) {
sendEvent(emitter, "step", stepEvent("completed", stepIndex, stepCount, operation));
}
};
}
private static Map<String, Object> stepEvent(
String phase, int stepIndex, int stepCount, String operation) {
return Map.of(
"phase", phase,
"stepIndex", stepIndex,
"stepCount", stepCount,
"operation", operation);
}
private static String terminalEventName(PolicyRun run) {
PolicyRunStatus status = run.getStatus();
return switch (status) {
case COMPLETED -> "completed";
case FAILED -> "failed";
case CANCELLED -> "cancelled";
case WAITING_FOR_INPUT -> "waiting";
default -> "ended";
};
}
private void sendEvent(SseEmitter emitter, String name, Object data) {
try {
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
} catch (IOException | IllegalStateException e) {
// Client disconnected or the emitter already closed. The run continues and its results
// remain downloadable via the job endpoints; nothing useful left to stream.
log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage());
}
}
private List<Resource> toResources(List<MultipartFile> files) throws IOException {
List<Resource> resources = new ArrayList<>();
if (files == null) {
return resources;
}
for (MultipartFile file : files) {
if (file == null || file.isEmpty()) {
continue;
}
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
resources.add(
new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return originalName;
}
});
}
return resources;
}
}
@@ -0,0 +1,261 @@
package stirling.software.proprietary.policy.engine;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.ResourceMonitor;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.ExecutorFactory;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.WaitState;
import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
/**
* Runs pipelines asynchronously as tracked jobs.
*
* <p>Each run is the unit of async work: {@link #submit} returns a run id immediately and the
* pipeline executes on a virtual thread, so a step blocking on a slow tool does not tie up a
* platform thread. The run drives {@link PolicyExecutor} for the actual step loop, registers its
* outputs and progress with {@link TaskManager} (so the existing job status/download endpoints work
* unchanged), and keeps rich state in {@link PolicyRunRegistry}.
*
* <p>The engine deliberately manages its own virtual-thread execution rather than routing through
* {@code JobExecutorService}: that path force-completes a job once its work returns, which is
* incompatible with a run that suspends in {@code WAITING_FOR_INPUT}. It still applies the shared
* {@link ResourceMonitor}/{@link JobQueue} admission control, so heavy runs queue under load
* instead of oversubscribing.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyEngine {
/**
* Resource weight of a pipeline run for admission control. A run chains many tools and holds
* intermediate files, so it is weighted as heavy work: the shared {@link ResourceMonitor}
* should let it start while the system is healthy but hold it back under memory/CPU pressure.
* See {@link ResourceMonitor#shouldQueueJob(int)} for how a weight maps to that decision.
*/
private static final int RUN_RESOURCE_WEIGHT = 50;
private final PolicyExecutor stepExecutor;
private final TaskManager taskManager;
private final PolicyRunRegistry registry;
private final FileStorage fileStorage;
private final JobOwnershipService jobOwnershipService;
private final List<PolicyOutputSink> outputSinks;
private final ResourceMonitor resourceMonitor;
private final JobQueue jobQueue;
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
/**
* Submit a pipeline to run asynchronously. The returned handle's run id scopes a job in {@link
* TaskManager}, so progress (notes), status, and result files are observable via the existing
* job endpoints as well as via {@link #getRun(String)}; its completion future resolves when the
* run reaches a terminal or paused state.
*/
public PolicyRunHandle submit(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
// Scope the run id to the current user (on this request thread) so the file-download
// ownership check passes; NoOpJobOwnershipService returns the id unchanged when security
// is off.
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
taskManager.createTask(runId);
PolicyRun run = new PolicyRun(runId, definition);
registry.register(run);
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
PolicyProgressListener tracking = trackingListener(runId, run, listener);
Runnable task = () -> runToCompletion(run, inputs, tracking, completion);
// Each run is one admission unit; steps run synchronously within it, so this gates heavy
// work under load without the pool-within-pool risk of queueing each tool call. Under
// resource pressure the run waits in the shared JobQueue; otherwise it starts immediately.
if (resourceMonitor.shouldQueueJob(RUN_RESOURCE_WEIGHT)) {
log.debug("Queueing policy run {} under resource pressure", runId);
jobQueue.queueJob(
runId,
RUN_RESOURCE_WEIGHT,
() -> {
task.run();
return null;
},
0L)
.exceptionally(ex -> failRejectedRun(run, completion, ex));
} else {
asyncExecutor.execute(task);
}
return new PolicyRunHandle(runId, completion);
}
/**
* Run a stored policy on demand. Builds the policy's pipeline and submits it. {@code enabled}
* gates automatic triggering, not explicit runs, so this runs regardless of that flag.
*/
public PolicyRunHandle runPolicy(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return submit(policy.toDefinition(), inputs, listener);
}
public PolicyRun getRun(String runId) {
return registry.get(runId);
}
/**
* Request cancellation of a run. Stage 1 marks the run cancelled in the registry if it has not
* already finished; interrupting an in-flight tool call lands in a later stage.
*/
public boolean cancel(String runId) {
PolicyRun run = registry.get(runId);
if (run == null) {
return false;
}
boolean cancelled = run.cancel();
if (cancelled) {
taskManager.addNote(runId, "Run cancelled by request");
}
return cancelled;
}
/**
* Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented; the run shape and
* {@link WaitState} snapshot are in place so this can be added without reworking the engine.
*/
public String resume(String runId, List<Resource> additionalInputs) {
throw new UnsupportedOperationException("Pause/resume is not yet implemented");
}
private void runToCompletion(
PolicyRun run,
PolicyInputs inputs,
PolicyProgressListener listener,
CompletableFuture<PolicyRun> completion) {
String runId = run.getRunId();
try {
run.markRunning();
PolicyExecutionResult result =
stepExecutor.execute(run.getDefinition(), inputs, listener);
OutputSpec output = run.getDefinition().output();
List<ResultFile> outputs = sinkFor(output).deliver(runId, result.files(), output);
taskManager.setMultipleFileResults(runId, outputs);
taskManager.setComplete(runId);
run.complete(outputs);
} catch (PolicyInputRequiredException e) {
// Designed-for path: suspend the run rather than fail it. Persist intermediates as
// fileIds so the run can resume after this worker thread is gone.
WaitState wait = suspend(e);
run.waitForInput(wait);
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
} catch (InternalApiTimeoutException e) {
String message = toolTimeoutMessage(e);
log.error(
"Policy run {} timed out on {}: {}",
runId,
e.getEndpointPath(),
e.getMessage());
run.fail(message);
taskManager.setError(runId, message);
} catch (Exception e) {
String message = "Policy run failed: " + e.getMessage();
log.error("Policy run {} failed", runId, e);
run.fail(message);
taskManager.setError(runId, message);
} finally {
// Always resolve the handle with the run's final state so stream/await callers unblock.
completion.complete(run);
}
}
private ResponseEntity<?> failRejectedRun(
PolicyRun run, CompletableFuture<PolicyRun> completion, Throwable ex) {
// Only reached if the run never started (e.g. the queue was full). A run that started
// always resolves its own completion in runToCompletion.
if (!completion.isDone()) {
String message = "Policy run could not be queued: " + ex.getMessage();
log.error("Policy run {} was not admitted: {}", run.getRunId(), ex.getMessage());
run.fail(message);
taskManager.setError(run.getRunId(), message);
completion.complete(run);
}
return null;
}
private WaitState suspend(PolicyInputRequiredException e) {
List<String> fileIds = new ArrayList<>();
for (Resource resource : e.getPendingFiles()) {
String name = resource.getFilename() != null ? resource.getFilename() : "pending";
try (InputStream is = resource.getInputStream()) {
fileIds.add(fileStorage.storeInputStream(is, name).fileId());
} catch (IOException io) {
log.warn("Failed to persist pending file for paused run: {}", io.getMessage());
}
}
return new WaitState(e.getMessage(), e.getResumeStepIndex(), fileIds);
}
private PolicyProgressListener trackingListener(
String runId, PolicyRun run, PolicyProgressListener delegate) {
return new PolicyProgressListener() {
@Override
public void onStepStart(int stepIndex, int stepCount, String operation) {
run.enterStep(stepIndex);
taskManager.addNote(
runId,
"Step " + stepIndex + "/" + stepCount + ": " + operation + " started");
delegate.onStepStart(stepIndex, stepCount, operation);
}
@Override
public void onStepComplete(int stepIndex, int stepCount, String operation) {
taskManager.addNote(
runId,
"Step " + stepIndex + "/" + stepCount + ": " + operation + " completed");
delegate.onStepComplete(stepIndex, stepCount, operation);
}
@Override
public void onHeartbeat() {
delegate.onHeartbeat();
}
};
}
private PolicyOutputSink sinkFor(OutputSpec spec) {
return outputSinks.stream()
.filter(sink -> sink.supports(spec))
.findFirst()
.orElseThrow(
() ->
new IllegalStateException(
"No output sink supports spec: "
+ (spec == null ? "<null>" : spec.type())));
}
private static String toolTimeoutMessage(InternalApiTimeoutException e) {
return String.format(
"The %s tool did not respond within %d seconds and was aborted.",
e.getEndpointPath(), e.getReadTimeout().toSeconds());
}
}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.core.io.Resource;
import tools.jackson.databind.JsonNode;
/**
* Result of running a pipeline through {@link PolicyExecutor}.
*
* <p>{@code files} are the final output resources (temp files, not yet stored to {@code
* FileStorage}). {@code report} is the structured metadata payload captured from the last step that
* produced one (a JSON body, or an {@code X-Stirling-Tool-Report} header), with {@code reportTool}
* naming the step it came from; both are null when no step produced a report.
*/
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
@@ -0,0 +1,312 @@
package stirling.software.proprietary.policy.engine;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.ZipExtractionUtils;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.service.AiToolResponseHeaders;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Runs an ordered chain of tool steps, chaining each step's output files into the next step's
* input.
*
* <p>This is the single execution loop for the proprietary surface (AI plans now;
* manually-triggered runs and watched folders later). Each step is dispatched synchronously via
* {@link InternalApiClient} loopback HTTP: the tool runs in its own handler and returns its file
* inline. The caller decides how to run the executor itself (the AI turn loop calls it directly;
* the engine runs it on a virtual thread for async runs). Files cross step boundaries as {@link
* Resource} temp files; they are only persisted to durable storage at the run boundaries by the
* caller.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyExecutor {
private static final String FILTER_OPERATION_PREFIX = "/api/v1/filter/filter-";
private final InternalApiClient internalApiClient;
private final ToolMetadataService toolMetadataService;
private final TempFileManager tempFileManager;
private final ObjectMapper objectMapper;
/**
* Internal value-class for tool responses. {@code files} holds any result files (typically one;
* multiple for ZIP-response tools). {@code report} holds an optional structured metadata
* payload the tool chose to surface alongside (or instead of) a file.
*/
private record ToolResult(List<Resource> files, JsonNode report) {}
/**
* Execute every step in {@code definition} in order, feeding each step's output into the next.
* Supporting files supplied in {@code inputs} are bound to steps' named file fields and never
* enter the document stream.
*
* @param definition the pipeline to run (must have at least one step)
* @param inputs the primary documents plus the named supporting-file store
* @param listener receives per-step progress
* @return the final output files plus the last structured report produced, if any
* @throws InternalApiTimeoutException if a tool does not respond within its read timeout
* @throws IOException if a tool returns a non-OK response, references a missing supporting
* file, or a file cannot be read
*/
public PolicyExecutionResult execute(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener)
throws IOException {
List<PipelineStep> steps = definition.steps();
if (steps.isEmpty()) {
throw new IllegalArgumentException("Pipeline definition has no steps");
}
List<Resource> currentFiles = inputs.primary();
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
// Propagate the *last* non-null report; the terminal step defines the output.
JsonNode lastReport = null;
String lastReportTool = null;
for (int i = 0; i < steps.size(); i++) {
PipelineStep step = steps.get(i);
String operation = step.operation();
if (operation == null || operation.isBlank()) {
throw new IllegalArgumentException(
"Pipeline step " + (i + 1) + " has no operation");
}
listener.onStepStart(i + 1, steps.size(), operation);
ToolResult stepResult = executeStep(step, currentFiles, supportingFiles);
currentFiles = stepResult.files();
if (stepResult.report() != null) {
lastReport = stepResult.report();
lastReportTool = operation;
}
listener.onStepComplete(i + 1, steps.size(), operation);
}
return new PolicyExecutionResult(currentFiles, lastReport, lastReportTool);
}
/**
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
*
* <p>A structured {@code report} may be returned alongside (or instead of) files; see {@link
* ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first
* non-null report wins.
*/
private ToolResult executeStep(
PipelineStep step,
List<Resource> inputFiles,
Map<String, List<Resource>> supportingFiles)
throws IOException {
requireAcceptedTypes(step.operation(), inputFiles);
List<Resource> files = new ArrayList<>();
JsonNode report = null;
if (toolMetadataService.isMultiInput(step.operation())) {
ToolResult r = callEndpoint(step, inputFiles, supportingFiles);
files.addAll(r.files());
report = r.report();
} else {
for (Resource file : inputFiles) {
ToolResult r = callEndpoint(step, List.of(file), supportingFiles);
files.addAll(r.files());
if (report == null) {
report = r.report();
}
}
}
return new ToolResult(files, report);
}
/**
* Call an endpoint and return its result files and optional report.
*
* <ul>
* <li>JSON body (Content-Type: application/json): the entire body is the report, no files are
* returned.
* <li>File body (PDF etc.): the file is returned; if an {@link
* AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is
* parsed as the report.
* <li>ZIP responses declared by the tool metadata service are unpacked so callers always see
* a flat list of result files.
* </ul>
*/
private ToolResult callEndpoint(
PipelineStep step, List<Resource> files, Map<String, List<Resource>> supportingFiles)
throws IOException {
String endpointPath = step.operation();
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
for (Resource file : files) {
body.add("fileInput", file);
}
// Bind supporting files to their named tool fields (e.g. stampImage, overlayFiles). These
// come from the run's named asset store, not the document stream.
for (Map.Entry<String, String> binding : step.fileParameters().entrySet()) {
String fieldName = binding.getKey();
String assetKey = binding.getValue();
List<Resource> assets = supportingFiles.get(assetKey);
if (assets == null || assets.isEmpty()) {
throw new IOException(
"Step "
+ endpointPath
+ " references supporting file '"
+ assetKey
+ "' for field '"
+ fieldName
+ "' but no such file was provided");
}
for (Resource asset : assets) {
body.add(fieldName, asset);
}
}
for (Map.Entry<String, Object> entry : step.parameters().entrySet()) {
if (entry.getValue() instanceof List<?> list) {
if (containsStructuredElements(list)) {
// Endpoints binding lists of structured objects (e.g. /security/redact's
// redactions, /general/edit-text's edits) parse a single JSON string field via
// a property editor. Pre-serialize the whole list so binding succeeds.
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
} else {
for (Object item : list) {
body.add(entry.getKey(), item);
}
}
} else {
body.add(entry.getKey(), entry.getValue());
}
}
ResponseEntity<Resource> response = internalApiClient.post(endpointPath, body);
if (!HttpStatus.OK.equals(response.getStatusCode()) || response.getBody() == null) {
throw new IOException(
"Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath);
}
Resource resource = response.getBody();
// Filter operations return an empty body to signal the file was filtered out: drop it
// rather than forwarding a zero-byte document.
if (isFilterOperation(endpointPath) && isEmpty(resource)) {
return new ToolResult(List.of(), null);
}
HttpHeaders headers = response.getHeaders();
MediaType contentType = headers.getContentType();
// JSON-only response: the whole body is the structured report, no result file.
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
try (InputStream is = resource.getInputStream()) {
JsonNode report = objectMapper.readTree(is);
return new ToolResult(List.of(), report);
}
}
JsonNode report = parseReportHeader(headers, endpointPath);
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
return new ToolResult(ZipExtractionUtils.extractZip(resource, tempFileManager), report);
}
return new ToolResult(List.of(resource), report);
}
/**
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
* or return null.
*/
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readTree(raw);
} catch (JacksonException e) {
log.warn(
"Ignoring malformed {} header from {}: {}",
AiToolResponseHeaders.TOOL_REPORT,
endpointPath,
e.getMessage());
return null;
}
}
private static boolean containsStructuredElements(List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> || item instanceof List<?>) {
return true;
}
}
return false;
}
/**
* Fail the run if any document in the primary stream is not a file type the step accepts. An
* endpoint that declares no specific input type accepts anything.
*/
private void requireAcceptedTypes(String operation, List<Resource> files) throws IOException {
List<String> accepted = toolMetadataService.getExtensionTypes(false, operation);
if (accepted == null || accepted.isEmpty()) {
return;
}
for (Resource file : files) {
if (!matchesType(file, accepted)) {
throw new IOException(
"Step "
+ operation
+ " accepts "
+ accepted
+ " but received '"
+ file.getFilename()
+ "'");
}
}
}
private static boolean matchesType(Resource file, List<String> acceptedExtensions) {
String filename = file.getFilename();
if (filename == null) {
return false;
}
int dot = filename.lastIndexOf('.');
if (dot < 0 || dot == filename.length() - 1) {
return false;
}
return acceptedExtensions.contains(filename.substring(dot + 1).toLowerCase(Locale.ROOT));
}
private static boolean isFilterOperation(String operation) {
return operation.startsWith(FILTER_OPERATION_PREFIX);
}
private static boolean isEmpty(Resource resource) {
try {
return resource.contentLength() == 0;
} catch (IOException e) {
return false;
}
}
}
@@ -0,0 +1,32 @@
package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.core.io.Resource;
import lombok.Getter;
/**
* Thrown by a step to signal that the run cannot proceed without further user input, pausing the
* run in {@code WAITING_FOR_INPUT} rather than failing it.
*
* <p>Carries everything needed to resume: a human-readable reason, the 0-based index of the step to
* resume from, and the intermediate files produced so far. The engine persists those files and
* suspends the run.
*
* <p>Defined now to fix the run shape; no step throws it yet, and the resume handshake is
* implemented in a later stage.
*/
@Getter
public class PolicyInputRequiredException extends RuntimeException {
private final transient List<Resource> pendingFiles;
private final int resumeStepIndex;
public PolicyInputRequiredException(
String reason, int resumeStepIndex, List<Resource> pendingFiles) {
super(reason);
this.resumeStepIndex = resumeStepIndex;
this.pendingFiles = pendingFiles == null ? List.of() : pendingFiles;
}
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.policy.engine;
import java.util.concurrent.CompletableFuture;
import stirling.software.proprietary.policy.model.PolicyRun;
/**
* Returned by {@link PolicyEngine#submit}: the run id (for status polling and result download) plus
* a future that resolves when the run reaches a terminal or paused state.
*
* <p>The completion future lets callers react to the end of a run (e.g. an SSE endpoint sending a
* final event and closing the stream) without polling. It carries the {@link PolicyRun} whose
* status describes the outcome (completed, failed, cancelled, or waiting for input); it does not
* complete exceptionally for ordinary run failures.
*/
public record PolicyRunHandle(String runId, CompletableFuture<PolicyRun> completion) {}
@@ -0,0 +1,97 @@
package stirling.software.proprietary.policy.engine;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
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.proprietary.policy.model.PolicyRun;
/**
* In-memory store of live {@link PolicyRun} state, keyed by runId. Holds the authoritative run
* state machine; durable status/files for download are projected separately into {@code
* TaskManager}.
*
* <p>Finished runs are evicted on a fixed interval once they age past {@code
* stirling.policies.runExpiryMinutes}, mirroring the job-result expiry in {@code TaskManager} so a
* run's rich in-memory state does not outlive the process. Only terminal runs are evicted; active
* and paused ({@code WAITING_FOR_INPUT}) runs are retained regardless of age. Result files are not
* touched here: a run shares its runId with a {@code TaskManager} job, which owns file-lifecycle
* cleanup, so eviction only frees this map's entry.
*/
@Slf4j
@Service
public class PolicyRunRegistry {
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
private final Duration runExpiry;
private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("policy-run-cleanup-", 0).factory());
public PolicyRunRegistry(
@Value("${stirling.policies.runExpiryMinutes:30}") int runExpiryMinutes) {
this.runExpiry = Duration.ofMinutes(runExpiryMinutes);
cleanupExecutor.scheduleAtFixedRate(this::evictExpiredRuns, 10, 10, TimeUnit.MINUTES);
log.debug(
"Policy run registry initialized with run expiry of {} minutes", runExpiryMinutes);
}
public void register(PolicyRun run) {
runs.put(run.getRunId(), run);
}
public PolicyRun get(String runId) {
return runs.get(runId);
}
public Collection<PolicyRun> all() {
return runs.values();
}
/** Scheduled hook: evict terminal runs that finished before the expiry window. */
private void evictExpiredRuns() {
try {
evictExpired(Instant.now().minus(runExpiry));
} catch (Exception e) {
log.error("Error during policy run cleanup: {}", e.getMessage(), e);
}
}
/**
* Remove every terminal run last updated before {@code cutoff}; active and paused runs are kept
* regardless of age. Returns the number evicted. Package-visible so the scheduled sweep and
* tests exercise the same path with an explicit cutoff.
*/
int evictExpired(Instant cutoff) {
int removed = 0;
for (Map.Entry<String, PolicyRun> entry : runs.entrySet()) {
PolicyRun run = entry.getValue();
if (run.getStatus().isTerminal() && run.getUpdatedAt().isBefore(cutoff)) {
runs.remove(entry.getKey());
removed++;
}
}
if (removed > 0) {
log.info("Evicted {} expired policy runs", removed);
}
return removed;
}
@PreDestroy
void shutdown() {
cleanupExecutor.shutdownNow();
}
}
@@ -0,0 +1,21 @@
package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* Describes where a pipeline run's output files should be delivered. {@code type} selects a {@code
* PolicyOutputSink} (e.g. "inline"); {@code options} carries sink-specific configuration.
*
* <p>New destinations (folder, S3) are added as new sink beans keyed on a new {@code type} without
* changing this shape or the engine.
*/
public record OutputSpec(String type, Map<String, Object> options) {
public OutputSpec {
options = options == null ? Map.of() : options;
}
/** The default destination: store outputs and return them to the caller for download. */
public static OutputSpec inline() {
return new OutputSpec("inline", Map.of());
}
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* An ordered chain of tool steps plus where the output should go.
*
* <p>This is the single shape executed by the policy engine, shared by AI plans, manually-triggered
* runs, and (later) watched folders. {@code output} may be null for callers that handle result
* files themselves (e.g. the AI workflow, which builds its own response payload).
*/
public record PipelineDefinition(String name, List<PipelineStep> steps, OutputSpec output) {
public PipelineDefinition {
steps = steps == null ? List.of() : steps;
}
}
@@ -0,0 +1,30 @@
package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* A single tool invocation in a pipeline: the API endpoint path to call and the inputs to pass.
*
* <p>{@code operation} is a Stirling tool endpoint path (e.g. {@code /api/v1/misc/compress-pdf}),
* matching the dispatch convention used by {@code InternalApiClient}. {@code parameters} are the
* tool-specific scalar form fields.
*
* <p>{@code fileParameters} binds a tool's named file fields (beyond the primary {@code fileInput}
* stream) to supporting files supplied with the run: it maps the form field name (e.g. {@code
* stampImage}, {@code overlayFiles}) to an asset key in the run's supporting-file store. This keeps
* supporting inputs (a stamp image, a certificate, an overlay) out of the document stream that
* flows step to step.
*/
public record PipelineStep(
String operation, Map<String, Object> parameters, Map<String, String> fileParameters) {
public PipelineStep {
parameters = parameters == null ? Map.of() : parameters;
fileParameters = fileParameters == null ? Map.of() : fileParameters;
}
/** A step with no supporting-file bindings. */
public PipelineStep(String operation, Map<String, Object> parameters) {
this(operation, parameters, Map.of());
}
}
@@ -0,0 +1,38 @@
package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* A stored, owned automation: how it is triggered, the ordered tool steps to run, and where its
* output goes, plus identity and metadata.
*
* <p>This is the central object of the feature. Everything that runs a chain of tools is a use of a
* Policy: a watched folder is a Policy with a folder {@link TriggerConfig} and a folder {@link
* OutputSpec}; a scheduled job is a Policy with a schedule trigger; manual/Automate/AI runs execute
* a Policy (or an ad-hoc {@link PipelineDefinition}) on demand. The engine itself only ever
* executes the {@link PipelineDefinition} this exposes via {@link #toDefinition()} - it is
* trigger-agnostic.
*
* <p>{@code enabled} gates automatic triggering (a disabled policy is not picked up by its
* trigger); it does not block an explicit manual run.
*/
public record Policy(
String id,
String name,
String owner,
boolean enabled,
TriggerConfig trigger,
List<PipelineStep> steps,
OutputSpec output) {
public Policy {
trigger = trigger == null ? TriggerConfig.manual() : trigger;
steps = steps == null ? List.of() : steps;
output = output == null ? OutputSpec.inline() : output;
}
/** The engine-level, trigger-agnostic view of this policy's pipeline. */
public PipelineDefinition toDefinition() {
return new PipelineDefinition(name, steps, output);
}
}
@@ -0,0 +1,32 @@
package stirling.software.proprietary.policy.model;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.Resource;
/**
* The files a run operates on, split into two roles:
*
* <ul>
* <li>{@code primary} - the documents that flow through the pipeline, each step's output becoming
* the next step's input.
* <li>{@code supportingFiles} - a named store of auxiliary files (a stamp image, certificate,
* overlay, attachments) that steps bind to their named file fields via {@link
* PipelineStep#fileParameters()}. These never enter the document stream.
* </ul>
*
* Asset values are lists so a single key can carry multi-file fields (e.g. attachments).
*/
public record PolicyInputs(List<Resource> primary, Map<String, List<Resource>> supportingFiles) {
public PolicyInputs {
primary = primary == null ? List.of() : primary;
supportingFiles = supportingFiles == null ? Map.of() : supportingFiles;
}
/** Inputs with primary documents only and no supporting files. */
public static PolicyInputs of(List<Resource> primary) {
return new PolicyInputs(primary, Map.of());
}
}
@@ -0,0 +1,87 @@
package stirling.software.proprietary.policy.model;
import java.time.Instant;
import java.util.List;
import lombok.Getter;
import stirling.software.common.model.job.ResultFile;
/**
* Live, mutable state of a single pipeline run, held in memory by {@code PolicyRunRegistry}.
*
* <p>This carries the rich execution state (status, step cursor, wait state) that the job system's
* {@code JobResult} does not model. The run is also projected into {@code TaskManager} for
* cluster-visible status, progress notes, and file download; this object is the authoritative
* source of the state machine.
*/
@Getter
public class PolicyRun {
private final String runId;
private final PipelineDefinition definition;
private final Instant createdAt = Instant.now();
private volatile PolicyRunStatus status = PolicyRunStatus.PENDING;
/** 1-based index of the step currently running (0 before the run starts). */
private volatile int currentStep = 0;
private volatile WaitState waitState;
private volatile String error;
private volatile List<ResultFile> outputs = List.of();
private volatile Instant updatedAt = Instant.now();
public PolicyRun(String runId, PipelineDefinition definition) {
this.runId = runId;
this.definition = definition;
}
public int stepCount() {
return definition.steps().size();
}
public synchronized void markRunning() {
this.status = PolicyRunStatus.RUNNING;
touch();
}
public synchronized void enterStep(int oneBasedStepIndex) {
this.currentStep = oneBasedStepIndex;
touch();
}
public synchronized void complete(List<ResultFile> resultFiles) {
this.outputs = resultFiles == null ? List.of() : List.copyOf(resultFiles);
this.status = PolicyRunStatus.COMPLETED;
touch();
}
public synchronized void fail(String message) {
this.error = message;
this.status = PolicyRunStatus.FAILED;
touch();
}
public synchronized void waitForInput(WaitState wait) {
this.waitState = wait;
this.status = PolicyRunStatus.WAITING_FOR_INPUT;
touch();
}
/**
* Mark cancelled if the run has not already reached a terminal state. Returns whether it did.
*/
public synchronized boolean cancel() {
if (status.isTerminal()) {
return false;
}
this.status = PolicyRunStatus.CANCELLED;
touch();
return true;
}
private void touch() {
this.updatedAt = Instant.now();
}
}
@@ -0,0 +1,21 @@
package stirling.software.proprietary.policy.model;
/**
* Lifecycle states of a {@link PolicyRun}.
*
* <p>{@code WAITING_FOR_INPUT} is modelled now so the engine and run shape support pausing a run
* (e.g. a step that blocks for a human decision) without holding a thread; the resume handshake is
* implemented in a later stage.
*/
public enum PolicyRunStatus {
PENDING,
RUNNING,
WAITING_FOR_INPUT,
COMPLETED,
FAILED,
CANCELLED;
public boolean isTerminal() {
return this == COMPLETED || this == FAILED || this == CANCELLED;
}
}
@@ -0,0 +1,28 @@
package stirling.software.proprietary.policy.model;
import java.util.List;
import stirling.software.common.model.job.ResultFile;
/**
* Read-only view of a {@link PolicyRun} returned by the status endpoint. Output files are surfaced
* as {@link ResultFile} so the caller can download each via {@code GET /api/v1/general/files/{id}}.
*/
public record PolicyRunView(
String runId,
PolicyRunStatus status,
int currentStep,
int stepCount,
String error,
List<ResultFile> outputs) {
public static PolicyRunView of(PolicyRun run) {
return new PolicyRunView(
run.getRunId(),
run.getStatus(),
run.getCurrentStep(),
run.stepCount(),
run.getError(),
run.getOutputs());
}
}
@@ -0,0 +1,25 @@
package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* How a {@link Policy} is automatically triggered. {@code type} selects a trigger kind ("manual",
* "folder", "schedule", "s3"); {@code options} carries type-specific configuration (a folder path,
* a cron expression, a bucket, ...).
*
* <p>Data-driven and parallel to {@link OutputSpec}: new trigger kinds are new {@code type} values
* handled by a new trigger bean, with no change to the model. {@code "manual"} means there is no
* automatic trigger - the policy is only ever run on demand.
*/
public record TriggerConfig(String type, Map<String, Object> options) {
public TriggerConfig {
type = type == null || type.isBlank() ? "manual" : type;
options = options == null ? Map.of() : options;
}
/** No automatic trigger; the policy is run on demand only. */
public static TriggerConfig manual() {
return new TriggerConfig("manual", Map.of());
}
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* Captured when a run pauses in {@link PolicyRunStatus#WAITING_FOR_INPUT}. Together with the run's
* {@link PipelineDefinition} this is the resumable snapshot: {@code resumeStepIndex} is the 0-based
* step to continue from, and {@code pendingFileIds} are the intermediate files (stored in {@code
* FileStorage}, so they survive the worker thread ending or a node restart) that become the input
* to the resumed run.
*
* <p>Stored as fileIds rather than in-memory resources by design: a paused run must be resumable
* long after its worker thread has gone.
*/
public record WaitState(String reason, int resumeStepIndex, List<String> pendingFileIds) {
public WaitState {
pendingFileIds = pendingFileIds == null ? List.of() : pendingFileIds;
}
}
@@ -0,0 +1,68 @@
package stirling.software.proprietary.policy.output;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* Default output sink: stores each output file in {@code FileStorage} so it is downloadable via
* {@code GET /api/v1/general/files/{fileId}}. This is the destination for manually-triggered runs
* whose results are returned to the caller.
*/
@Service
@RequiredArgsConstructor
public class InlineOutputSink implements PolicyOutputSink {
private static final String TYPE = "inline";
private final FileStorage fileStorage;
@Override
public String type() {
return TYPE;
}
@Override
public boolean supports(OutputSpec spec) {
return spec == null || spec.type() == null || TYPE.equals(spec.type());
}
@Override
public List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
throws IOException {
List<ResultFile> results = new ArrayList<>();
for (int i = 0; i < outputs.size(); i++) {
Resource resource = outputs.get(i);
String name =
resource.getFilename() != null ? resource.getFilename() : "result-" + (i + 1);
String contentType =
MediaTypeFactory.getMediaType(name)
.orElse(MediaType.APPLICATION_OCTET_STREAM)
.toString();
FileStorage.StoredFile stored;
try (InputStream is = resource.getInputStream()) {
stored = fileStorage.storeInputStream(is, name);
}
results.add(
ResultFile.builder()
.fileId(stored.fileId())
.fileName(name)
.contentType(contentType)
.fileSize(stored.size())
.build());
}
return results;
}
}
@@ -0,0 +1,35 @@
package stirling.software.proprietary.policy.output;
import java.io.IOException;
import java.util.List;
import org.springframework.core.io.Resource;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* Delivers a finished run's output files to a destination, returning durable {@link ResultFile}
* descriptors (fileId + metadata) for the run record.
*
* <p>Implementations are Spring beans selected by {@link #supports(OutputSpec)}. New destinations
* (folder, S3) are added as new beans without changing the engine.
*/
public interface PolicyOutputSink {
/** Stable identifier for this sink, matching {@code OutputSpec.type()} (e.g. "inline"). */
String type();
/** Whether this sink can handle the given output spec. */
boolean supports(OutputSpec spec);
/**
* Persist/deliver the output files and return their descriptors.
*
* @param runId the run these outputs belong to
* @param outputs the final pipeline output resources
* @param spec the requested destination
*/
List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
throws IOException;
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.policy.progress;
/**
* Receives live progress as a pipeline run executes. Implementations forward to an SSE stream,
* write job notes for polling, or both. Step indices are 1-based.
*
* <p>All methods default to no-ops so callers implement only what they surface.
*/
public interface PolicyProgressListener {
/** A listener that ignores all progress. */
PolicyProgressListener NOOP = new PolicyProgressListener() {};
/** Called immediately before step {@code stepIndex} of {@code stepCount} begins. */
default void onStepStart(int stepIndex, int stepCount, String operation) {}
/** Called immediately after step {@code stepIndex} of {@code stepCount} completes. */
default void onStepComplete(int stepIndex, int stepCount, String operation) {}
/** Called on a keep-alive tick so downstream connections can detect disconnects promptly. */
default void onHeartbeat() {}
}
@@ -0,0 +1,61 @@
package stirling.software.proprietary.policy.store;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.policy.model.Policy;
/**
* In-memory {@link PolicyStore}. Not the runtime bean - {@link JpaPolicyStore} is the durable
* store. Kept as a lightweight, dependency-free implementation for tests and for any future no-
* database mode.
*/
public class InProcessPolicyStore implements PolicyStore {
private final Map<String, Policy> policies = new ConcurrentHashMap<>();
@Override
public Policy save(Policy policy) {
String id =
policy.id() == null || policy.id().isBlank()
? UUID.randomUUID().toString()
: policy.id();
Policy stored =
new Policy(
id,
policy.name(),
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.steps(),
policy.output());
policies.put(id, stored);
return stored;
}
@Override
public Optional<Policy> get(String id) {
return Optional.ofNullable(policies.get(id));
}
@Override
public List<Policy> all() {
return List.copyOf(policies.values());
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return policies.values().stream()
.filter(Policy::enabled)
.filter(policy -> triggerType.equals(policy.trigger().type()))
.toList();
}
@Override
public boolean delete(String id) {
return policies.remove(id) != null;
}
}
@@ -0,0 +1,83 @@
package stirling.software.proprietary.policy.store;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.model.Policy;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link PolicyStore} backed by JPA. The runtime store whenever the proprietary module runs
* (a datasource is always present). Policies are persisted as JSON via {@link PolicyEntity}; the
* scalar columns are kept in sync for querying.
*/
@Service
@RequiredArgsConstructor
public class JpaPolicyStore implements PolicyStore {
private final PolicyRepository repository;
private final ObjectMapper objectMapper;
@Override
public Policy save(Policy policy) {
String id =
policy.id() == null || policy.id().isBlank()
? UUID.randomUUID().toString()
: policy.id();
Policy stored =
new Policy(
id,
policy.name(),
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.steps(),
policy.output());
PolicyEntity entity = new PolicyEntity();
entity.setId(id);
entity.setName(stored.name());
entity.setOwner(stored.owner());
entity.setEnabled(stored.enabled());
entity.setTriggerType(stored.trigger().type());
entity.setPolicyJson(objectMapper.writeValueAsString(stored));
repository.save(entity);
return stored;
}
@Override
public Optional<Policy> get(String id) {
return repository.findById(id).map(this::toPolicy);
}
@Override
public List<Policy> all() {
return repository.findAll().stream().map(this::toPolicy).toList();
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream()
.map(this::toPolicy)
.toList();
}
@Override
public boolean delete(String id) {
if (!repository.existsById(id)) {
return false;
}
repository.deleteById(id);
return true;
}
private Policy toPolicy(PolicyEntity entity) {
return objectMapper.readValue(entity.getPolicyJson(), Policy.class);
}
}
@@ -0,0 +1,50 @@
package stirling.software.proprietary.policy.store;
import java.io.Serializable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}.
*
* <p>The whole policy is stored as JSON in {@code policyJson} (authoritative on read, and the same
* serialization the API uses); the scalar columns are denormalized copies for querying - notably
* {@code triggerType} + {@code enabled} so background triggers can fetch their policies. Ownership
* is a plain {@code owner} string rather than a foreign key, to stay decoupled from the security
* entities; richer team scoping can be layered on later.
*/
@Entity
@Table(name = "policies")
@NoArgsConstructor
@Getter
@Setter
public class PolicyEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "id")
private String id;
@Column(name = "name")
private String name;
@Column(name = "owner")
private String owner;
@Column(name = "enabled")
private boolean enabled;
@Column(name = "trigger_type")
private String triggerType;
@Column(name = "policy_json", columnDefinition = "text")
private String policyJson;
}
@@ -0,0 +1,13 @@
package stirling.software.proprietary.policy.store;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface PolicyRepository extends JpaRepository<PolicyEntity, String> {
/** Enabled policies of a given trigger type, for background triggers to activate. */
List<PolicyEntity> findByTriggerTypeAndEnabledTrue(String triggerType);
}
@@ -0,0 +1,28 @@
package stirling.software.proprietary.policy.store;
import java.util.List;
import java.util.Optional;
import stirling.software.proprietary.policy.model.Policy;
/**
* Stores {@link Policy} definitions. The in-memory implementation backs simple deployments now; a
* durable (JPA) implementation can replace it behind this interface without touching callers.
*/
public interface PolicyStore {
/** Create or update a policy. A blank/absent id is assigned; returns the stored policy. */
Policy save(Policy policy);
Optional<Policy> get(String id);
List<Policy> all();
/**
* Enabled policies whose automatic trigger is of the given type (used by background triggers).
*/
List<Policy> findByTriggerType(String triggerType);
/** Remove a policy; returns whether it existed. */
boolean delete(String id);
}
@@ -0,0 +1,41 @@
package stirling.software.proprietary.policy.trigger;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.engine.PolicyEngine;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
/**
* Runs policies on demand, in response to a request (the {@code PolicyController} endpoints, an AI,
* or another automation). It is the request-driven trigger: no background lifecycle, it just
* forwards to the engine. Any policy can be run manually regardless of its configured trigger type.
*/
@Service
@RequiredArgsConstructor
public class ManualTrigger implements PolicyTrigger {
private final PolicyEngine policyEngine;
@Override
public String type() {
return "manual";
}
/** Run a stored policy immediately and return its run handle. */
public PolicyRunHandle run(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return policyEngine.runPolicy(policy, inputs, listener);
}
/** Run an ad-hoc pipeline (no stored policy), e.g. for AI or Automate one-offs. */
public PolicyRunHandle fire(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
return policyEngine.submit(definition, inputs, listener);
}
}
@@ -0,0 +1,24 @@
package stirling.software.proprietary.policy.trigger;
/**
* Activates policies of one trigger type. A trigger owns a {@link #type()} (matching {@code
* TriggerConfig.type()}); when its condition fires it runs the relevant {@code Policy} through the
* {@code PolicyEngine}.
*
* <p>Background triggers (folder watcher, schedule) are driven by configuration: on {@link
* #start()} they begin watching/scheduling for the policies returned by {@code
* PolicyStore.findByTriggerType(type())}, and stop on {@link #stop()}. Request-driven triggers
* (manual) have no background lifecycle and run a policy directly in response to a call. New
* trigger kinds are new beans of this type; the engine and the {@code Policy} model do not change.
*/
public interface PolicyTrigger {
/** Stable identifier for this trigger kind, matching {@code TriggerConfig.type()}. */
String type();
/** Begin activating policies of this type (e.g. start a folder watcher). No-op for manual. */
default void start() {}
/** Stop activating and release any resources. */
default void stop() {}
}
@@ -31,13 +31,15 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.security.repository", "stirling.software.proprietary.security.repository",
"stirling.software.proprietary.repository", "stirling.software.proprietary.repository",
"stirling.software.proprietary.storage.repository", "stirling.software.proprietary.storage.repository",
"stirling.software.proprietary.workflow.repository" "stirling.software.proprietary.workflow.repository",
"stirling.software.proprietary.policy.store"
}) })
@EntityScan({ @EntityScan({
"stirling.software.proprietary.security.model", "stirling.software.proprietary.security.model",
"stirling.software.proprietary.model", "stirling.software.proprietary.model",
"stirling.software.proprietary.storage.model", "stirling.software.proprietary.storage.model",
"stirling.software.proprietary.workflow.model" "stirling.software.proprietary.workflow.model",
"stirling.software.proprietary.policy.store"
}) })
public class DatabaseConfig { public class DatabaseConfig {
@@ -14,14 +14,9 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory; import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames; import io.github.pixee.security.Filenames;
@@ -32,14 +27,11 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.FileStorage; import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException; import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.service.UserServiceInterface; import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.ZipExtractionUtils;
import stirling.software.proprietary.model.api.ai.AiConversationMessage; import stirling.software.proprietary.model.api.ai.AiConversationMessage;
import stirling.software.proprietary.model.api.ai.AiDocumentIngestRequest; import stirling.software.proprietary.model.api.ai.AiDocumentIngestRequest;
import stirling.software.proprietary.model.api.ai.AiEngineProgressDetail; import stirling.software.proprietary.model.api.ai.AiEngineProgressDetail;
@@ -53,6 +45,13 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest; import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse; import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile; import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
import stirling.software.proprietary.policy.engine.PolicyExecutionResult;
import stirling.software.proprietary.policy.engine.PolicyExecutor;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.security.util.DesktopClientUtils; import stirling.software.proprietary.security.util.DesktopClientUtils;
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile; import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult; import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
@@ -72,12 +71,11 @@ public class AiWorkflowService {
private final AiEngineClient aiEngineClient; private final AiEngineClient aiEngineClient;
private final PdfContentExtractor pdfContentExtractor; private final PdfContentExtractor pdfContentExtractor;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final InternalApiClient internalApiClient;
private final FileStorage fileStorage; private final FileStorage fileStorage;
private final ToolMetadataService toolMetadataService;
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
private final FileIdStrategy fileIdStrategy; private final FileIdStrategy fileIdStrategy;
private final AiEngineEndpointResolver endpointResolver; private final AiEngineEndpointResolver endpointResolver;
private final PolicyExecutor policyExecutor;
private final UserServiceInterface userService; private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
@@ -86,24 +84,22 @@ public class AiWorkflowService {
AiEngineClient aiEngineClient, AiEngineClient aiEngineClient,
PdfContentExtractor pdfContentExtractor, PdfContentExtractor pdfContentExtractor,
ObjectMapper objectMapper, ObjectMapper objectMapper,
InternalApiClient internalApiClient,
FileStorage fileStorage, FileStorage fileStorage,
ToolMetadataService toolMetadataService,
TempFileManager tempFileManager, TempFileManager tempFileManager,
FileIdStrategy fileIdStrategy, FileIdStrategy fileIdStrategy,
AiEngineEndpointResolver endpointResolver, AiEngineEndpointResolver endpointResolver,
PolicyExecutor policyExecutor,
@Autowired(required = false) UserServiceInterface userService, @Autowired(required = false) UserServiceInterface userService,
ApplicationProperties applicationProperties) { ApplicationProperties applicationProperties) {
this.pdfDocumentFactory = pdfDocumentFactory; this.pdfDocumentFactory = pdfDocumentFactory;
this.aiEngineClient = aiEngineClient; this.aiEngineClient = aiEngineClient;
this.pdfContentExtractor = pdfContentExtractor; this.pdfContentExtractor = pdfContentExtractor;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.internalApiClient = internalApiClient;
this.fileStorage = fileStorage; this.fileStorage = fileStorage;
this.toolMetadataService = toolMetadataService;
this.tempFileManager = tempFileManager; this.tempFileManager = tempFileManager;
this.fileIdStrategy = fileIdStrategy; this.fileIdStrategy = fileIdStrategy;
this.endpointResolver = endpointResolver; this.endpointResolver = endpointResolver;
this.policyExecutor = policyExecutor;
this.userService = userService; this.userService = userService;
this.applicationProperties = applicationProperties; this.applicationProperties = applicationProperties;
} }
@@ -150,16 +146,6 @@ public class AiWorkflowService {
record Terminal(AiWorkflowResponse response) implements WorkflowState {} record Terminal(AiWorkflowResponse response) implements WorkflowState {}
} }
/**
* Internal value-class for tool responses. {@code files} holds any result files (typically one;
* multiple for ZIP-response tools). {@code report} holds an optional structured metadata
* payload the tool chose to surface alongside (or instead of) a file.
*
* <p>Tools populate the report either by returning a JSON body (whole body → report) or by
* adding the {@link AiToolResponseHeaders#TOOL_REPORT} header alongside a file body.
*/
private record ToolResult(List<Resource> files, JsonNode report) {}
public AiWorkflowResponse orchestrate(AiWorkflowRequest request) throws IOException { public AiWorkflowResponse orchestrate(AiWorkflowRequest request) throws IOException {
return orchestrate(request, NOOP_LISTENER); return orchestrate(request, NOOP_LISTENER);
} }
@@ -377,7 +363,6 @@ public class AiWorkflowService {
pages.size()); pages.size());
} }
@SuppressWarnings("unchecked")
private WorkflowState onToolCall( private WorkflowState onToolCall(
AiWorkflowResponse response, AiWorkflowResponse response,
Map<String, MultipartFile> filesById, Map<String, MultipartFile> filesById,
@@ -394,8 +379,14 @@ public class AiWorkflowService {
try { try {
List<Resource> inputFiles = toResources(filesById); List<Resource> inputFiles = toResources(filesById);
listener.onProgress(AiWorkflowProgressEvent.executingTool(endpointPath, 1, 1)); PipelineDefinition definition =
ToolResult result = executeStep(endpointPath, parameters, inputFiles); new PipelineDefinition(
null,
List.of(new PipelineStep(endpointPath, parameters)),
OutputSpec.inline());
PolicyExecutionResult result =
policyExecutor.execute(
definition, PolicyInputs.of(inputFiles), stepProgress(listener));
return new WorkflowState.Terminal( return new WorkflowState.Terminal(
buildCompletedResponse( buildCompletedResponse(
response.getRationale(), response.getRationale(),
@@ -469,38 +460,32 @@ public class AiWorkflowService {
cannotContinue("AI engine returned a plan with no steps.")); cannotContinue("AI engine returned a plan with no steps."));
} }
try { List<PipelineStep> pipelineSteps = new ArrayList<>();
List<Resource> currentFiles = toResources(filesById);
// Propagate the *last* non-null report — the terminal step defines the output.
JsonNode lastReport = null;
String lastReportTool = null;
for (int i = 0; i < steps.size(); i++) { for (int i = 0; i < steps.size(); i++) {
Map<String, Object> step = steps.get(i); Map<String, Object> step = steps.get(i);
String endpointPath = (String) step.get("tool"); String endpointPath = (String) step.get("tool");
Map<String, Object> parameters =
step.containsKey("parameters")
? (Map<String, Object>) step.get("parameters")
: Map.of();
if (endpointPath == null || endpointPath.isBlank()) { if (endpointPath == null || endpointPath.isBlank()) {
return new WorkflowState.Terminal( return new WorkflowState.Terminal(
cannotContinue("Plan step " + (i + 1) + " has no tool endpoint.")); cannotContinue("Plan step " + (i + 1) + " has no tool endpoint."));
} }
Map<String, Object> parameters =
step.containsKey("parameters")
? (Map<String, Object>) step.get("parameters")
: Map.of();
pipelineSteps.add(new PipelineStep(endpointPath, parameters));
}
listener.onProgress( try {
AiWorkflowProgressEvent.executingTool(endpointPath, i + 1, steps.size())); List<Resource> inputFiles = toResources(filesById);
ToolResult stepResult = executeStep(endpointPath, parameters, currentFiles); PipelineDefinition definition =
currentFiles = stepResult.files(); new PipelineDefinition(summary, pipelineSteps, OutputSpec.inline());
if (stepResult.report() != null) { PolicyExecutionResult result =
lastReport = stepResult.report(); policyExecutor.execute(
lastReportTool = endpointPath; definition, PolicyInputs.of(inputFiles), stepProgress(listener));
}
}
// Multi-turn: if the plan was emitted with resume_with set, the delegate wants // Multi-turn: if the plan was emitted with resume_with set, the delegate wants
// Java to re-invoke the orchestrator with any captured report as an artifact. // Java to re-invoke the orchestrator with any captured report as an artifact.
if (resumeWith != null && !resumeWith.isBlank() && lastReport != null) { if (resumeWith != null && !resumeWith.isBlank() && result.report() != null) {
WorkflowTurnRequest resumeRequest = new WorkflowTurnRequest(); WorkflowTurnRequest resumeRequest = new WorkflowTurnRequest();
resumeRequest.setUserMessage(previousRequest.getUserMessage()); resumeRequest.setUserMessage(previousRequest.getUserMessage());
resumeRequest.setFiles(previousRequest.getFiles()); resumeRequest.setFiles(previousRequest.getFiles());
@@ -510,14 +495,14 @@ public class AiWorkflowService {
.getArtifacts() .getArtifacts()
.add( .add(
new PdfContentExtractor.ToolReportArtifact( new PdfContentExtractor.ToolReportArtifact(
lastReportTool, lastReport)); result.reportTool(), result.report()));
resumeRequest.setResumeWith(resumeWith); resumeRequest.setResumeWith(resumeWith);
return new WorkflowState.Pending(resumeRequest); return new WorkflowState.Pending(resumeRequest);
} }
return new WorkflowState.Terminal( return new WorkflowState.Terminal(
buildCompletedResponse( buildCompletedResponse(
summary, currentFiles, inputFileNames(filesById), lastReport)); summary, result.files(), inputFileNames(filesById), result.report()));
} catch (InternalApiTimeoutException e) { } catch (InternalApiTimeoutException e) {
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage()); log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
return new WorkflowState.Terminal( return new WorkflowState.Terminal(
@@ -548,123 +533,19 @@ public class AiWorkflowService {
} }
/** /**
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one * Adapt the AI workflow's {@link ProgressListener} to the engine's {@link
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each * PolicyProgressListener}: each step start maps to an {@code EXECUTING_TOOL} progress event
* inner file is treated as its own result (e.g. split outputs a ZIP of pages). * carrying the tool path and 1-based step position, preserving the event shape the frontend
* * already renders.
* <p>A structured {@code report} may be returned alongside (or instead of) files — see {@link
* ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first
* non-null report wins.
*/ */
private ToolResult executeStep( private static PolicyProgressListener stepProgress(ProgressListener listener) {
String endpointPath, Map<String, Object> parameters, List<Resource> inputFiles) return new PolicyProgressListener() {
throws IOException { @Override
List<Resource> files = new ArrayList<>(); public void onStepStart(int stepIndex, int stepCount, String operation) {
JsonNode report = null; listener.onProgress(
if (toolMetadataService.isMultiInput(endpointPath)) { AiWorkflowProgressEvent.executingTool(operation, stepIndex, stepCount));
ToolResult r = callEndpoint(endpointPath, parameters, inputFiles);
files.addAll(r.files());
report = r.report();
} else {
for (Resource file : inputFiles) {
ToolResult r = callEndpoint(endpointPath, parameters, List.of(file));
files.addAll(r.files());
if (report == null) {
report = r.report();
} }
} };
}
return new ToolResult(files, report);
}
/**
* Call an endpoint and return its result files and optional report.
*
* <ul>
* <li>JSON body (Content-Type: application/json) → the entire body is the report, no files
* are returned.
* <li>File body (PDF etc.) → the file is returned; if an {@link
* AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is
* parsed as the report.
* <li>ZIP responses declared by the tool metadata service are unpacked so callers always see
* a flat list of result files.
* </ul>
*/
private ToolResult callEndpoint(
String endpointPath, Map<String, Object> parameters, List<Resource> files)
throws IOException {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
for (Resource file : files) {
body.add("fileInput", file);
}
for (Map.Entry<String, Object> entry : parameters.entrySet()) {
if (entry.getValue() instanceof List<?> list) {
if (containsStructuredElements(list)) {
// Endpoints binding lists of structured objects (e.g. /security/redact's
// redactions, /general/edit-text's edits) parse a single JSON string field via
// a property editor. Pre-serialize the whole list so binding succeeds.
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
} else {
for (Object item : list) {
body.add(entry.getKey(), item);
}
}
} else {
body.add(entry.getKey(), entry.getValue());
}
}
ResponseEntity<Resource> response = internalApiClient.post(endpointPath, body);
if (!HttpStatus.OK.equals(response.getStatusCode()) || response.getBody() == null) {
throw new IOException(
"Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath);
}
Resource resource = response.getBody();
HttpHeaders headers = response.getHeaders();
MediaType contentType = headers.getContentType();
// JSON-only response — the whole body is the structured report, no result file.
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
try (java.io.InputStream is = resource.getInputStream()) {
JsonNode report = objectMapper.readTree(is);
return new ToolResult(List.of(), report);
}
}
JsonNode report = parseReportHeader(headers, endpointPath);
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
return new ToolResult(ZipExtractionUtils.extractZip(resource, tempFileManager), report);
}
return new ToolResult(List.of(resource), report);
}
/**
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
* or return null.
*/
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readTree(raw);
} catch (JacksonException e) {
log.warn(
"Ignoring malformed {} header from {}: {}",
AiToolResponseHeaders.TOOL_REPORT,
endpointPath,
e.getMessage());
return null;
}
}
private static boolean containsStructuredElements(List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> || item instanceof List<?>) {
return true;
}
}
return false;
} }
private List<Resource> toResources(Map<String, MultipartFile> filesById) throws IOException { private List<Resource> toResources(Map<String, MultipartFile> filesById) throws IOException {
@@ -0,0 +1,255 @@
package stirling.software.proprietary.policy.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.FileStorage.StoredFile;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.ResourceMonitor;
import stirling.software.common.service.TaskManager;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.output.InlineOutputSink;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import tools.jackson.databind.json.JsonMapper;
/**
* Tests for {@link PolicyEngine}: async submission runs the pipeline on a virtual thread, registers
* outputs and progress with {@link TaskManager}, and surfaces terminal state via {@link
* PolicyRunRegistry}. The step executor and inline sink are real (with mocked collaborators) so the
* full run path is exercised.
*/
@ExtendWith(MockitoExtension.class)
class PolicyEngineTest {
private static final String ROTATE = "/api/v1/general/rotate-pdf";
private static final String COMPRESS = "/api/v1/misc/compress-pdf";
@Mock private InternalApiClient internalApiClient;
@Mock private ToolMetadataService toolMetadataService;
@Mock private TaskManager taskManager;
@Mock private FileStorage fileStorage;
@Mock private JobOwnershipService jobOwnershipService;
@Mock private ResourceMonitor resourceMonitor;
@Mock private JobQueue jobQueue;
@TempDir Path tempDir;
private PolicyRunRegistry registry;
private PolicyEngine engine;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("policy-engine-test-");
TempFileManager tempFileManager = new TempFileManager(new TempFileRegistry(), props);
PolicyExecutor executor =
new PolicyExecutor(
internalApiClient,
toolMetadataService,
tempFileManager,
JsonMapper.builder().build());
registry = new PolicyRunRegistry(30);
InlineOutputSink sink = new InlineOutputSink(fileStorage);
engine =
new PolicyEngine(
executor,
taskManager,
registry,
fileStorage,
jobOwnershipService,
List.of(sink),
resourceMonitor,
jobQueue);
// Identity scoping: the run id is the generated UUID unchanged. Lenient because the
// resume/cancel tests do not submit a run.
lenient()
.when(jobOwnershipService.createScopedJobKey(anyString()))
.thenAnswer(inv -> inv.getArgument(0));
// Default to running immediately; the queueing test overrides this.
lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false);
}
@Test
void submitRunsPipelineToCompletionAndRegistersOutputs() throws Exception {
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
stubEndpoint(COMPRESS, pdf("compressed", "compressed.pdf"));
int[] counter = {0};
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
.thenAnswer(
inv -> {
InputStream is = inv.getArgument(0);
long size = is.readAllBytes().length;
return new StoredFile("file-" + ++counter[0], size);
});
PolicyRunHandle handle =
engine.submit(
definition(
new PipelineStep(ROTATE, Map.of()),
new PipelineStep(COMPRESS, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP);
// The completion future resolves with the final run state, no polling needed.
String runId = handle.runId();
PolicyRun run = handle.completion().get(10, TimeUnit.SECONDS);
assertEquals(PolicyRunStatus.COMPLETED, run.getStatus());
assertEquals(1, run.getOutputs().size());
assertEquals("compressed.pdf", run.getOutputs().get(0).getFileName());
// The run self-registers its results and completion with the job system.
verify(taskManager).createTask(runId);
verify(taskManager).setMultipleFileResults(eq(runId), any());
verify(taskManager).setComplete(runId);
// Progress notes were written for each step.
verify(taskManager, atLeastOnce()).addNote(eq(runId), anyString());
}
@Test
void submitFailsRunWhenAToolErrors() throws Exception {
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
PolicyRunHandle handle =
engine.submit(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP);
String runId = handle.runId();
PolicyRun run = handle.completion().get(10, TimeUnit.SECONDS);
assertEquals(PolicyRunStatus.FAILED, run.getStatus());
verify(taskManager).setError(eq(runId), anyString());
verify(taskManager, never()).setComplete(runId);
}
@Test
void runPolicyExecutesThePolicysPipeline() throws Exception {
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
int[] counter = {0};
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
.thenAnswer(
inv -> {
InputStream is = inv.getArgument(0);
return new StoredFile("file-" + ++counter[0], is.readAllBytes().length);
});
Policy policy =
new Policy(
"p1",
"rotate",
"owner",
true,
TriggerConfig.manual(),
List.of(new PipelineStep(ROTATE, Map.of())),
OutputSpec.inline());
PolicyRunHandle handle =
engine.runPolicy(
policy,
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP);
PolicyRun run = handle.completion().get(10, TimeUnit.SECONDS);
assertEquals(PolicyRunStatus.COMPLETED, run.getStatus());
verify(internalApiClient).post(eq(ROTATE), any());
}
@Test
void runIsQueuedUnderResourcePressure() {
when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true);
// Returning an already-completed future keeps the run parked: the queued work (which would
// start the run) is never executed by this mock, so it stays PENDING.
doReturn(CompletableFuture.completedFuture(null))
.when(jobQueue)
.queueJob(anyString(), anyInt(), any(), anyLong());
PolicyRunHandle handle =
engine.submit(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP);
verify(jobQueue).queueJob(eq(handle.runId()), anyInt(), any(), anyLong());
assertEquals(PolicyRunStatus.PENDING, registry.get(handle.runId()).getStatus());
}
@Test
void resumeIsNotYetImplemented() {
assertThrows(UnsupportedOperationException.class, () -> engine.resume("any", List.of()));
}
@Test
void cancelUnknownRunReturnsFalse() {
assertFalse(engine.cancel("does-not-exist"));
}
// --- helpers ---
private static PipelineDefinition definition(PipelineStep... steps) {
return new PipelineDefinition("test", List.of(steps), OutputSpec.inline());
}
private void stubEndpoint(String endpoint, Resource body) {
when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body));
}
private static ByteArrayResource pdf(String content, String filename) {
return new ByteArrayResource(content.getBytes()) {
@Override
public String getFilename() {
return filename;
}
};
}
}
@@ -0,0 +1,378 @@
package stirling.software.proprietary.policy.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
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.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Unit tests for {@link PolicyExecutor}, the shared pipeline step loop. Covers file chaining across
* steps, multi-input vs per-file dispatch, ZIP unpacking, structured-list parameter encoding,
* progress callbacks, and timeout propagation. External collaborators are mocked; {@link
* TempFileManager} is real so ZIP extraction exercises real code.
*/
@ExtendWith(MockitoExtension.class)
class PolicyExecutorTest {
private static final String ROTATE = "/api/v1/general/rotate-pdf";
private static final String COMPRESS = "/api/v1/misc/compress-pdf";
private static final String SPLIT = "/api/v1/general/split-pages";
private static final String MERGE = "/api/v1/general/merge-pdfs";
@Mock private InternalApiClient internalApiClient;
@Mock private ToolMetadataService toolMetadataService;
@TempDir Path tempDir;
private TempFileManager tempFileManager;
private PolicyExecutor executor;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("policy-test-");
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
ObjectMapper objectMapper = JsonMapper.builder().build();
executor =
new PolicyExecutor(
internalApiClient, toolMetadataService, tempFileManager, objectMapper);
}
@Test
void executesStepsSequentiallyChainingOutputToInput() throws IOException {
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
stubEndpoint(COMPRESS, pdf("compressed", "compressed.pdf"));
List<Integer> steps = new ArrayList<>();
PolicyProgressListener listener =
new PolicyProgressListener() {
@Override
public void onStepStart(int stepIndex, int stepCount, String operation) {
steps.add(stepIndex);
}
};
PolicyExecutionResult result =
executor.execute(
definition(
new PipelineStep(ROTATE, Map.of()),
new PipelineStep(COMPRESS, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
listener);
assertEquals(1, result.files().size());
assertEquals("compressed.pdf", result.files().get(0).getFilename());
verify(internalApiClient, times(1)).post(eq(ROTATE), any());
verify(internalApiClient, times(1)).post(eq(COMPRESS), any());
// Progress fired once per step, in order.
assertEquals(List.of(1, 2), steps);
}
@Test
void multiInputEndpointIsCalledOnceWithAllFiles() throws IOException {
when(toolMetadataService.isMultiInput(MERGE)).thenReturn(true);
when(toolMetadataService.shouldUnpackZipResponse(MERGE)).thenReturn(false);
stubEndpoint(MERGE, pdf("merged", "merged.pdf"));
PolicyExecutionResult result =
executor.execute(
definition(new PipelineStep(MERGE, Map.of())),
PolicyInputs.of(List.of(pdf("a", "a.pdf"), pdf("b", "b.pdf"))),
PolicyProgressListener.NOOP);
assertEquals(1, result.files().size());
verify(internalApiClient, times(1)).post(eq(MERGE), any());
}
@Test
void singleInputEndpointIsCalledOncePerFile() throws IOException {
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(ROTATE)).thenReturn(false);
stubEndpoint(ROTATE, pdf("rotated", "rotated.pdf"));
PolicyExecutionResult result =
executor.execute(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("a", "a.pdf"), pdf("b", "b.pdf"))),
PolicyProgressListener.NOOP);
assertEquals(2, result.files().size());
verify(internalApiClient, times(2)).post(eq(ROTATE), any());
}
@Test
void zipResponseIsUnpackedIntoIndividualFiles() throws IOException {
when(toolMetadataService.isMultiInput(SPLIT)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(SPLIT)).thenReturn(true);
stubEndpoint(
SPLIT,
zip(
"doc.zip",
List.of(new Entry("page-1.pdf", "one"), new Entry("page-2.pdf", "two"))));
PolicyExecutionResult result =
executor.execute(
definition(new PipelineStep(SPLIT, Map.of())),
PolicyInputs.of(List.of(pdf("doc", "doc.pdf"))),
PolicyProgressListener.NOOP);
assertEquals(2, result.files().size());
assertEquals("page-1.pdf", result.files().get(0).getFilename());
assertEquals("page-2.pdf", result.files().get(1).getFilename());
}
@Test
void structuredListParameterIsJsonEncodedAsSingleField() throws IOException {
String editText = "/api/v1/general/edit-text";
when(toolMetadataService.isMultiInput(editText)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(editText)).thenReturn(false);
stubEndpoint(editText, pdf("edited", "edited.pdf"));
// LinkedHashMap so the serialized key order is deterministic for the assertion below.
Map<String, Object> edit = new LinkedHashMap<>();
edit.put("find", "foo");
edit.put("replace", "bar");
Map<String, Object> params = new LinkedHashMap<>();
params.put("edits", List.of(edit));
params.put("useRegex", false);
executor.execute(
definition(new PipelineStep(editText, params)),
PolicyInputs.of(List.of(pdf("in", "in.pdf"))),
PolicyProgressListener.NOOP);
@SuppressWarnings("unchecked")
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
ArgumentCaptor.forClass(MultiValueMap.class);
verify(internalApiClient).post(eq(editText), bodyCaptor.capture());
MultiValueMap<String, Object> body = bodyCaptor.getValue();
List<Object> edits = body.get("edits");
assertNotNull(edits);
assertEquals(1, edits.size());
assertEquals("[{\"find\":\"foo\",\"replace\":\"bar\"}]", edits.get(0));
}
@Test
void supportingFilesAreBoundToTheirNamedFields() throws IOException {
String addStamp = "/api/v1/misc/add-stamp-to-pdf";
when(toolMetadataService.isMultiInput(addStamp)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(addStamp)).thenReturn(false);
stubEndpoint(addStamp, pdf("stamped", "stamped.pdf"));
PipelineStep step =
new PipelineStep(addStamp, Map.of("opacity", 0.5), Map.of("stampImage", "logo"));
PolicyInputs inputs =
new PolicyInputs(
List.of(pdf("doc", "doc.pdf")),
Map.of("logo", List.of(pdf("logo-bytes", "logo.png"))));
executor.execute(
new PipelineDefinition("stamp", List.of(step), OutputSpec.inline()),
inputs,
PolicyProgressListener.NOOP);
@SuppressWarnings("unchecked")
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
ArgumentCaptor.forClass(MultiValueMap.class);
verify(internalApiClient).post(eq(addStamp), bodyCaptor.capture());
MultiValueMap<String, Object> body = bodyCaptor.getValue();
// The document goes to fileInput; the supporting image is bound to its named field and is
// not part of the document stream.
assertEquals(1, body.get("fileInput").size());
assertNotNull(body.get("stampImage"));
assertEquals(1, body.get("stampImage").size());
}
@Test
void missingSupportingFileFailsTheStep() {
String addStamp = "/api/v1/misc/add-stamp-to-pdf";
when(toolMetadataService.isMultiInput(addStamp)).thenReturn(false);
PipelineStep step = new PipelineStep(addStamp, Map.of(), Map.of("stampImage", "logo"));
IOException ex =
assertThrows(
IOException.class,
() ->
executor.execute(
new PipelineDefinition(
"stamp", List.of(step), OutputSpec.inline()),
PolicyInputs.of(List.of(pdf("doc", "doc.pdf"))),
PolicyProgressListener.NOOP));
assertTrue(ex.getMessage().contains("logo"));
}
@Test
void documentOfAnUnacceptedTypeFailsTheStep() {
String compress = "/api/v1/misc/compress-pdf";
when(toolMetadataService.getExtensionTypes(false, compress)).thenReturn(List.of("pdf"));
IOException ex =
assertThrows(
IOException.class,
() ->
executor.execute(
definition(new PipelineStep(compress, Map.of())),
PolicyInputs.of(List.of(pdf("img", "image.png"))),
PolicyProgressListener.NOOP));
assertTrue(ex.getMessage().contains("image.png"));
// Type check happens before any dispatch.
verify(internalApiClient, never()).post(anyString(), any());
}
@Test
void documentOfAnAcceptedTypeProceeds() throws IOException {
String compress = "/api/v1/misc/compress-pdf";
when(toolMetadataService.getExtensionTypes(false, compress)).thenReturn(List.of("pdf"));
when(toolMetadataService.isMultiInput(compress)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(compress)).thenReturn(false);
stubEndpoint(compress, pdf("compressed", "compressed.pdf"));
PolicyExecutionResult result =
executor.execute(
definition(new PipelineStep(compress, Map.of())),
PolicyInputs.of(List.of(pdf("doc", "doc.pdf"))),
PolicyProgressListener.NOOP);
assertEquals(1, result.files().size());
verify(internalApiClient, times(1)).post(eq(compress), any());
}
@Test
void filterOperationWithEmptyResultDropsTheFile() throws IOException {
String filter = "/api/v1/filter/filter-page-count";
when(toolMetadataService.isMultiInput(filter)).thenReturn(false);
stubEndpoint(filter, pdf("", "filtered.pdf")); // empty body => filtered out
PolicyExecutionResult result =
executor.execute(
definition(new PipelineStep(filter, Map.of())),
PolicyInputs.of(List.of(pdf("doc", "doc.pdf"))),
PolicyProgressListener.NOOP);
assertEquals(0, result.files().size());
}
@Test
void timeoutFromAStepPropagates() {
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
when(internalApiClient.post(eq(ROTATE), any()))
.thenThrow(
new InternalApiTimeoutException(
ROTATE,
java.time.Duration.ofSeconds(300),
new IOException("Read timed out")));
assertThrows(
InternalApiTimeoutException.class,
() ->
executor.execute(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("in", "in.pdf"))),
PolicyProgressListener.NOOP));
}
@Test
void emptyPipelineIsRejected() {
assertThrows(
IllegalArgumentException.class,
() ->
executor.execute(
new PipelineDefinition("empty", List.of(), OutputSpec.inline()),
PolicyInputs.of(List.of(pdf("in", "in.pdf"))),
PolicyProgressListener.NOOP));
}
// --- helpers ---
private static PipelineDefinition definition(PipelineStep... steps) {
return new PipelineDefinition("test", List.of(steps), OutputSpec.inline());
}
private void stubEndpoint(String endpoint, Resource body) {
when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body));
}
private static ByteArrayResource pdf(String content, String filename) {
return new ByteArrayResource(content.getBytes()) {
@Override
public String getFilename() {
return filename;
}
};
}
private static ByteArrayResource zip(String filename, List<Entry> entries) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (Entry entry : entries) {
zos.putNextEntry(new ZipEntry(entry.name()));
zos.write(entry.content().getBytes());
zos.closeEntry();
}
}
byte[] zipBytes = baos.toByteArray();
return new ByteArrayResource(zipBytes) {
@Override
public String getFilename() {
return filename;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(zipBytes);
}
};
}
private record Entry(String name, String content) {}
}
@@ -0,0 +1,101 @@
package stirling.software.proprietary.policy.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.WaitState;
/**
* Tests for {@link PolicyRunRegistry} eviction: terminal runs expire, active/paused runs persist.
*/
class PolicyRunRegistryTest {
private PolicyRunRegistry registry;
@BeforeEach
void setUp() {
registry = new PolicyRunRegistry(30);
}
@AfterEach
void tearDown() {
registry.shutdown();
}
@Test
void evictsTerminalRunsPastTheCutoff() {
PolicyRun completed = register("completed");
completed.complete(List.of());
PolicyRun failed = register("failed");
failed.fail("boom");
PolicyRun cancelled = register("cancelled");
cancelled.cancel();
// A cutoff in the future means every terminal run finished "before" it.
int removed = registry.evictExpired(Instant.now().plusSeconds(60));
assertEquals(3, removed);
assertNull(registry.get("completed"));
assertNull(registry.get("failed"));
assertNull(registry.get("cancelled"));
}
@Test
void retainsActiveAndPausedRunsRegardlessOfAge() {
register("pending"); // PENDING: never started
PolicyRun running = register("running");
running.markRunning();
PolicyRun waiting = register("waiting");
waiting.waitForInput(new WaitState("needs a signature", 1, List.of()));
int removed = registry.evictExpired(Instant.now().plusSeconds(60));
assertEquals(0, removed);
assertNotNull(registry.get("pending"));
assertNotNull(registry.get("running"));
assertNotNull(registry.get("waiting"));
}
@Test
void keepsTerminalRunsStillWithinTheExpiryWindow() {
PolicyRun completed = register("recent");
completed.complete(List.of());
// A cutoff in the past means the run was updated "after" it: too young to evict.
int removed = registry.evictExpired(Instant.now().minusSeconds(60));
assertEquals(0, removed);
assertNotNull(registry.get("recent"));
}
@Test
void evictionLeavesUnrelatedRunsInPlace() {
PolicyRun completed = register("done");
completed.complete(List.of());
PolicyRun running = register("busy");
running.markRunning();
registry.evictExpired(Instant.now().plusSeconds(60));
assertNull(registry.get("done"));
assertNotNull(registry.get("busy"));
assertTrue(registry.all().stream().anyMatch(r -> r.getRunId().equals("busy")));
}
private PolicyRun register(String runId) {
PolicyRun run = new PolicyRun(runId, new PipelineDefinition(runId, List.of(), null));
registry.register(run);
return run;
}
}
@@ -0,0 +1,87 @@
package stirling.software.proprietary.policy.store;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
/** Tests for {@link InProcessPolicyStore}: id assignment, upsert, trigger-type lookup, delete. */
class InProcessPolicyStoreTest {
private PolicyStore store;
@BeforeEach
void setUp() {
store = new InProcessPolicyStore();
}
@Test
void savedPolicyGetsAnIdAndIsRetrievable() {
Policy saved = store.save(policy(null, "compress", "manual", true));
assertNotNull(saved.id());
assertFalse(saved.id().isBlank());
assertEquals(saved, store.get(saved.id()).orElseThrow());
}
@Test
void savingWithAnExistingIdUpdatesInPlace() {
Policy created = store.save(policy(null, "before", "manual", true));
store.save(
new Policy(
created.id(),
"after",
"owner",
true,
TriggerConfig.manual(),
List.of(),
OutputSpec.inline()));
assertEquals(1, store.all().size());
assertEquals("after", store.get(created.id()).orElseThrow().name());
}
@Test
void findByTriggerTypeReturnsOnlyEnabledMatches() {
store.save(policy(null, "watch", "folder", true));
store.save(policy(null, "watch-disabled", "folder", false));
store.save(policy(null, "nightly", "schedule", true));
List<Policy> folder = store.findByTriggerType("folder");
assertEquals(1, folder.size());
assertEquals("watch", folder.get(0).name());
}
@Test
void deleteRemovesThePolicy() {
Policy saved = store.save(policy(null, "p", "manual", true));
assertTrue(store.delete(saved.id()));
assertTrue(store.get(saved.id()).isEmpty());
assertFalse(store.delete(saved.id()));
}
private static Policy policy(String id, String name, String triggerType, boolean enabled) {
return new Policy(
id,
name,
"owner",
enabled,
new TriggerConfig(triggerType, Map.of()),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
}
@@ -0,0 +1,129 @@
package stirling.software.proprietary.policy.store;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Tests for {@link JpaPolicyStore}'s entity mapping and query delegation. The repository is mocked;
* real Hibernate/H2 persistence is exercised at application boot (this module's convention is
* Mockito unit tests for store/service logic).
*/
@ExtendWith(MockitoExtension.class)
class JpaPolicyStoreTest {
@Mock private PolicyRepository repository;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private JpaPolicyStore store;
@BeforeEach
void setUp() {
store = new JpaPolicyStore(repository, objectMapper);
}
@Test
void saveAssignsAnIdAndPersistsThePolicyAsJson() {
Policy saved =
store.save(
new Policy(
null,
"compress incoming",
"alice",
true,
new TriggerConfig("folder", Map.of("path", "/in")),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
assertNotNull(saved.id());
ArgumentCaptor<PolicyEntity> captor = ArgumentCaptor.forClass(PolicyEntity.class);
verify(repository).save(captor.capture());
PolicyEntity entity = captor.getValue();
assertEquals(saved.id(), entity.getId());
assertEquals("folder", entity.getTriggerType());
assertTrue(entity.isEnabled());
// The stored JSON round-trips back to an equal policy.
assertEquals(saved, objectMapper.readValue(entity.getPolicyJson(), Policy.class));
}
@Test
void getDeserializesThePolicyFromJson() {
Policy policy =
new Policy(
"p1",
"rotate",
"alice",
true,
TriggerConfig.manual(),
List.of(
new PipelineStep(
"/api/v1/general/rotate-pdf", Map.of("angle", 90))),
OutputSpec.inline());
when(repository.findById("p1")).thenReturn(Optional.of(entityFor(policy)));
assertEquals(policy, store.get("p1").orElseThrow());
}
@Test
void findByTriggerTypeUsesTheEnabledQuery() {
Policy policy =
new Policy(
"p1",
"watch",
"alice",
true,
new TriggerConfig("folder", Map.of()),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
when(repository.findByTriggerTypeAndEnabledTrue("folder"))
.thenReturn(List.of(entityFor(policy)));
List<Policy> folder = store.findByTriggerType("folder");
assertEquals(1, folder.size());
assertEquals("p1", folder.get(0).id());
}
@Test
void deleteReturnsWhetherThePolicyExisted() {
when(repository.existsById("p1")).thenReturn(true);
assertTrue(store.delete("p1"));
verify(repository).deleteById("p1");
when(repository.existsById("missing")).thenReturn(false);
assertFalse(store.delete("missing"));
}
private PolicyEntity entityFor(Policy policy) {
PolicyEntity entity = new PolicyEntity();
entity.setId(policy.id());
entity.setName(policy.name());
entity.setOwner(policy.owner());
entity.setEnabled(policy.enabled());
entity.setTriggerType(policy.trigger().type());
entity.setPolicyJson(objectMapper.writeValueAsString(policy));
return entity;
}
}
@@ -57,6 +57,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput;
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome; import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest; import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse; import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.policy.engine.PolicyExecutor;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.json.JsonMapper;
@@ -107,18 +108,20 @@ class AiWorkflowServiceTest {
.when(fileIdStrategy.idFor(any(MultipartFile.class))) .when(fileIdStrategy.idFor(any(MultipartFile.class)))
.thenAnswer(inv -> ((MultipartFile) inv.getArgument(0)).getOriginalFilename()); .thenAnswer(inv -> ((MultipartFile) inv.getArgument(0)).getOriginalFilename());
PolicyExecutor policyExecutor =
new PolicyExecutor(
internalApiClient, toolMetadataService, tempFileManager, objectMapper);
service = service =
new AiWorkflowService( new AiWorkflowService(
pdfDocumentFactory, pdfDocumentFactory,
aiEngineClient, aiEngineClient,
pdfContentExtractor, pdfContentExtractor,
objectMapper, objectMapper,
internalApiClient,
fileStorage, fileStorage,
toolMetadataService,
tempFileManager, tempFileManager,
fileIdStrategy, fileIdStrategy,
endpointResolver, endpointResolver,
policyExecutor,
null, null,
new ApplicationProperties()); new ApplicationProperties());
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of()); when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
@@ -12,7 +12,6 @@ cancel = "Cancel"
changedCredsMessage = "Credentials changed!" changedCredsMessage = "Credentials changed!"
chooseFile = "Choose File" chooseFile = "Choose File"
close = "Close" close = "Close"
color = "Colour"
comingSoon = "Coming soon" comingSoon = "Coming soon"
confirm = "Confirm" confirm = "Confirm"
confirmClose = "Confirm Close" confirmClose = "Confirm Close"
@@ -199,6 +198,10 @@ ssoDescription = "Two-factor authentication is managed by your identity provider
ssoManaged = "Configure MFA through your identity provider." ssoManaged = "Configure MFA through your identity provider."
title = "Two-factor authentication" title = "Two-factor authentication"
[account.overview]
logOut = "Log out"
signedInAs = "Signed in as: {{email}}"
[add-page-numbers] [add-page-numbers]
tags = "paginate,label,organize,index" tags = "paginate,label,organize,index"
@@ -226,6 +229,9 @@ title = "What it does"
[AddAttachmentsRequest.tooltip.header] [AddAttachmentsRequest.tooltip.header]
title = "About Add Attachments" title = "About Add Attachments"
[addFileCard]
upload = "Upload"
[addImage] [addImage]
applySignatures = "Apply Images" applySignatures = "Apply Images"
header = "Add images to PDFs" header = "Add images to PDFs"
@@ -606,6 +612,7 @@ label = "Temp File Management"
[admin.settings.advanced.tempFileManagement.baseTmpDir] [admin.settings.advanced.tempFileManagement.baseTmpDir]
description = "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)" description = "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)"
label = "Base Temp Directory" label = "Base Temp Directory"
placeholder = "Default: java.io.tmpdir/stirling-pdf"
[admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes] [admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes]
description = "How often to run cleanup (in minutes)" description = "How often to run cleanup (in minutes)"
@@ -618,6 +625,7 @@ label = "Cleanup System Temp"
[admin.settings.advanced.tempFileManagement.libreofficeDir] [admin.settings.advanced.tempFileManagement.libreofficeDir]
description = "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)" description = "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)"
label = "LibreOffice Temp Directory" label = "LibreOffice Temp Directory"
placeholder = "Default: baseTmpDir/libreoffice"
[admin.settings.advanced.tempFileManagement.maxAgeHours] [admin.settings.advanced.tempFileManagement.maxAgeHours]
description = "Maximum age in hours before temp files are cleaned up" description = "Maximum age in hours before temp files are cleaned up"
@@ -634,6 +642,7 @@ label = "Startup Cleanup"
[admin.settings.advanced.tempFileManagement.systemTempDir] [admin.settings.advanced.tempFileManagement.systemTempDir]
description = "System temp directory to clean (only used if cleanupSystemTemp is enabled)" description = "System temp directory to clean (only used if cleanupSystemTemp is enabled)"
label = "System Temp Directory" label = "System Temp Directory"
placeholder = "System temp directory path"
[admin.settings.advanced.tessdataDir] [admin.settings.advanced.tessdataDir]
description = "Path to the tessdata directory for OCR language files" description = "Path to the tessdata directory for OCR language files"
@@ -839,6 +848,7 @@ label = "Database Name"
[admin.settings.database.password] [admin.settings.database.password]
description = "Database authentication password" description = "Database authentication password"
label = "Password" label = "Password"
placeholder = "Enter database password"
[admin.settings.database.port] [admin.settings.database.port]
description = "Database server port (not used if custom URL is provided)" description = "Database server port (not used if custom URL is provided)"
@@ -870,10 +880,12 @@ label = "Hide unavailable tools by default"
[admin.settings.endpoints.groupsToRemove] [admin.settings.endpoints.groupsToRemove]
description = "Select endpoint groups to disable" description = "Select endpoint groups to disable"
label = "Disabled Endpoint Groups" label = "Disabled Endpoint Groups"
placeholder = "Select groups to disable"
[admin.settings.endpoints.toRemove] [admin.settings.endpoints.toRemove]
description = "Select individual endpoints to disable" description = "Select individual endpoints to disable"
label = "Disabled Endpoints" label = "Disabled Endpoints"
placeholder = "Select endpoints to disable"
[admin.settings.enterpriseRequired] [admin.settings.enterpriseRequired]
message = "An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference." message = "An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference."
@@ -1088,6 +1100,7 @@ label = "SMTP Host"
[admin.settings.mail.password] [admin.settings.mail.password]
description = "Password for SMTP authentication" description = "Password for SMTP authentication"
label = "SMTP Password" label = "SMTP Password"
placeholder = "Enter SMTP password"
[admin.settings.mail.port] [admin.settings.mail.port]
description = "The port number for SMTP connection (typically 25, 465, or 587)" description = "The port number for SMTP connection (typically 25, 465, or 587)"
@@ -1097,6 +1110,10 @@ label = "SMTP Port"
description = "Username for SMTP authentication" description = "Username for SMTP authentication"
label = "SMTP Username" label = "SMTP Username"
[admin.settings.plan.noData]
message = "Plans data is not available at the moment."
title = "No data available"
[admin.settings.premium] [admin.settings.premium]
description = "Configure your premium or enterprise license key." description = "Configure your premium or enterprise license key."
license = "License Configuration" license = "License Configuration"
@@ -1143,6 +1160,9 @@ info = "If you have a license key or certificate file from a direct purchase, yo
toggle = "Got a license key or certificate file?" toggle = "Got a license key or certificate file?"
[admin.settings.premium.movedFeatures] [admin.settings.premium.movedFeatures]
auditLogging = "<0>Audit Logging</0> (ENTERPRISE) - Security"
customMetadata = "<0>Custom Metadata</0> (PRO) - General"
databaseConfiguration = "<0>Database Configuration</0> (ENTERPRISE) - Database"
message = "Premium and Enterprise features are now organized in their respective sections:" message = "Premium and Enterprise features are now organized in their respective sections:"
title = "Premium Features Distributed" title = "Premium Features Distributed"
@@ -1526,10 +1546,10 @@ section_title = "Agents"
show_less = "Show less" show_less = "Show less"
start_chat = "Start chatting" start_chat = "Start chatting"
stirling_description = "Your general-purpose PDF assistant" stirling_description = "Your general-purpose PDF assistant"
stirling_running = "Running..."
stirling_full_name = "Stirling General Agent" stirling_full_name = "Stirling General Agent"
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents." stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
stirling_name = "Stirling" stirling_name = "Stirling"
stirling_running = "Running..."
stirling_tooltip = "Stirling agent" stirling_tooltip = "Stirling agent"
view_all = "View all agents" view_all = "View all agents"
@@ -2126,11 +2146,31 @@ placeholder = "Number of pages"
title = "Last N Pages" title = "Last N Pages"
[bulkSelection.operators] [bulkSelection.operators]
text = "AND has higher precedence than comma. NOT applies within the document range."
title = "Operators"
[bulkSelection.operators.and]
title = "Combine selections (both conditions must be true)"
[bulkSelection.operators.descriptions]
and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)" and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)"
comma = "Comma: , or | - combine selections (e.g., 1-10, 20)" comma = "Comma: , or | - combine selections (e.g., 1-10, 20)"
not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)" not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)"
text = "AND has higher precedence than comma. NOT applies within the document range."
title = "Operators" [bulkSelection.operators.even]
title = "Select all even-numbered pages (2, 4, 6, 8...)"
[bulkSelection.operators.not]
title = "Exclude from selection"
[bulkSelection.operators.odd]
title = "Select all odd-numbered pages (1, 3, 5, 7...)"
[bulkSelection.operators.or]
title = "Add to selection (either condition can be true)"
[bulkSelection.pageSelection]
title = "Page Selection"
[bulkSelection.range] [bulkSelection.range]
fromPlaceholder = "From" fromPlaceholder = "From"
@@ -2782,6 +2822,9 @@ unknownTool = "Unknown tool"
[cloudBadge] [cloudBadge]
tooltip = "This operation will use your cloud credits" tooltip = "This operation will use your cloud credits"
[color.eyeDropper]
tooltip = "Pick colour from screen"
[colorPicker] [colorPicker]
title = "Choose colour" title = "Choose colour"
@@ -3006,6 +3049,10 @@ title = "Compression Method"
1 = "1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality" 1 = "1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality"
_value = "Compression Settings" _value = "Compression Settings"
[compress.settings]
desiredSize = "Desired File Size"
desiredSizePlaceholder = "Enter size"
[compress.tooltip.description] [compress.tooltip.description]
text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually." text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually."
title = "Description" title = "Description"
@@ -3496,6 +3543,13 @@ docsLink = "View installation documentation"
message = "Stirling-PDF does not have permission to update itself on this machine." message = "Stirling-PDF does not have permission to update itself on this machine."
title = "Administrator permissions required" title = "Administrator permissions required"
[dropdownList]
searchPlaceholder = "Search..."
[editableSecretField]
edit = "Edit"
editSecretValue = "Edit secret value"
[editTableOfContents] [editTableOfContents]
submit = "Apply table of contents" submit = "Apply table of contents"
@@ -3583,6 +3637,9 @@ tabTitle = "Outline workspace"
description = "Select the Edit Table of Contents tool to load its workspace." description = "Select the Edit Table of Contents tool to load its workspace."
title = "Open the tool to start editing" title = "Open the tool to start editing"
[emptyFilesState]
upload = "Upload"
[encryptedPdfUnlock] [encryptedPdfUnlock]
description = "This PDF is password protected. Enter the password so you can continue working with it." description = "This PDF is password protected. Enter the password so you can continue working with it."
emptyResponse = "Password removal did not produce a file." emptyResponse = "Password removal did not produce a file."
@@ -3697,6 +3754,10 @@ title = "Settings"
[extractPages.tooltip] [extractPages.tooltip]
description = "Extracts the selected pages into a new PDF, preserving order." description = "Extracts the selected pages into a new PDF, preserving order."
[fileCard]
openInFileEditor = "Open in File Editor"
viewInViewer = "View in Viewer"
[fileChooser] [fileChooser]
click = "Click" click = "Click"
dragAndDrop = "Drag & Drop" dragAndDrop = "Drag & Drop"
@@ -3706,6 +3767,9 @@ extractPDF = "Extracting..."
hoveredDragAndDrop = "Drag & Drop file(s) here" hoveredDragAndDrop = "Drag & Drop file(s) here"
or = "or" or = "or"
[fileDropdownMenu]
closeFile = "Close file"
[fileEditor] [fileEditor]
addFiles = "Add Files" addFiles = "Add Files"
pageCount = "{{count}} pages" pageCount = "{{count}} pages"
@@ -3872,6 +3936,10 @@ openSettings = "Open settings"
search = "Search" search = "Search"
searchPlaceholder = "Search files..." searchPlaceholder = "Search files..."
[fileSidebar.fileItem]
closeViewer = "Close viewer"
openInViewer = "Open in viewer"
[filesPage] [filesPage]
addToWorkspace = "Add to workspace" addToWorkspace = "Add to workspace"
addToWorkspaceCount = "Add {{count}} to workspace" addToWorkspaceCount = "Add {{count}} to workspace"
@@ -4178,6 +4246,15 @@ title = "About Flattening PDFs"
discord = "Discord" discord = "Discord"
issues = "GitHub" issues = "GitHub"
[formFill]
flattenAfterFilling = "Flatten after filling"
rescanFields = "Re-scan fields"
rescanFormFields = "Re-scan form fields"
save = "Save"
[formFill.sidebar]
close = "Close sidebar"
[getPdfInfo] [getPdfInfo]
downloadJson = "Download JSON" downloadJson = "Download JSON"
downloads = "Downloads" downloads = "Downloads"
@@ -4737,6 +4814,11 @@ tags = "markup,web-content,transformation,convert"
title = "HTML To PDF" title = "HTML To PDF"
zoom = "Zoom level for displaying the website." zoom = "Zoom level for displaying the website."
[image.upload]
hint = "Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility."
label = "Upload Image"
placeholder = "Select image file"
[imageToPdf] [imageToPdf]
tags = "conversion,img,jpg,picture,photo" tags = "conversion,img,jpg,picture,photo"
@@ -4755,6 +4837,9 @@ title = "Image to PDF"
4 = "Merge into single PDF" 4 = "Merge into single PDF"
5 = "Convert to separate PDFs" 5 = "Convert to separate PDFs"
[infoBanner]
dismiss = "Dismiss"
[invite] [invite]
acceptError = "Failed to create account" acceptError = "Failed to create account"
accountFor = "Creating account for" accountFor = "Creating account for"
@@ -5090,6 +5175,7 @@ failed = "OCR operation failed"
[ocr.languagePicker] [ocr.languagePicker]
additionalLanguages = "Looking for additional languages?" additionalLanguages = "Looking for additional languages?"
loading = "Loading available languages..."
viewSetupGuide = "View setup guide →" viewSetupGuide = "View setup guide →"
[ocr.operation] [ocr.operation]
@@ -5226,6 +5312,10 @@ daysRemainingSingular = "{{days}} day remaining"
title = "Your 30-Day Pro Trial" title = "Your 30-Day Pro Trial"
trialEnds = "Trial ends {{date}}" trialEnds = "Trial ends {{date}}"
[onboarding.mfa]
authenticationCode = "Authentication code"
qrCodeLoading = "Generating your QR code…"
[onboarding.planOverview] [onboarding.planOverview]
adminBodyLoginDisabled = "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge." adminBodyLoginDisabled = "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
adminBodyLoginEnabled = "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge." adminBodyLoginEnabled = "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
@@ -5235,6 +5325,9 @@ userTitle = "Plan Overview"
[onboarding.securityCheck] [onboarding.securityCheck]
message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue." message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
roleAdmin = "Admin"
rolePlaceholder = "Confirm your role"
roleUser = "User"
[onboarding.serverLicense] [onboarding.serverLicense]
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo." freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
@@ -5360,6 +5453,17 @@ title = "Page Editor"
zoomIn = "Zoom In" zoomIn = "Zoom In"
zoomOut = "Zoom Out" zoomOut = "Zoom Out"
[pageEditor.emptyState]
body = "Add files to start editing pages"
title = "No PDF files loaded"
[pageEditor.toolbar]
delete = "Delete Selected"
redo = "Redo"
rotateLeft = "Rotate Selected Left"
rotateRight = "Rotate Selected Right"
undo = "Undo"
[pageExtracter] [pageExtracter]
header = "Extract Pages" header = "Extract Pages"
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
@@ -5371,12 +5475,14 @@ addBorder = "Add Borders"
borderWidth = "Border Thickness" borderWidth = "Border Thickness"
bottom = "Bottom Margin" bottom = "Bottom Margin"
cols = "Columns" cols = "Columns"
colsPlaceholder = "Enter columns"
header = "Multi Page Layout" header = "Multi Page Layout"
innerMargin = "Inner Margin" innerMargin = "Inner Margin"
left = "Left Margin" left = "Left Margin"
pagesPerSheet = "Pages per sheet:" pagesPerSheet = "Pages per sheet:"
right = "Right Margin" right = "Right Margin"
rows = "Rows" rows = "Rows"
rowsPlaceholder = "Enter rows"
submit = "Submit" submit = "Submit"
tags = "merge,composite,single-view,organize" tags = "merge,composite,single-view,organize"
title = "Multi Page Layout" title = "Multi Page Layout"
@@ -5418,6 +5524,14 @@ innerMarginTooLarge = "Inner margin is too large for the selected layout."
outerHorizontalMarginsTooLarge = "Left/Right margins are too large for this page size." outerHorizontalMarginsTooLarge = "Left/Right margins are too large for this page size."
outerVerticalMarginsTooLarge = "Top/Bottom margins are too large for this page size." outerVerticalMarginsTooLarge = "Top/Bottom margins are too large for this page size."
[pageLayout.margins]
borderThicknessPlaceholder = "Enter border thickness"
bottomPlaceholder = "Enter bottom margin"
innerPlaceholder = "Enter inner margin"
leftPlaceholder = "Enter left margin"
rightPlaceholder = "Enter right margin"
topPlaceholder = "Enter top margin"
[pageLayout.marginsBorders.tooltip.borders] [pageLayout.marginsBorders.tooltip.borders]
text = "Enable borders to draw lines around each placed page. This can help visual separation or trimming." text = "Enable borders to draw lines around each placed page. This can help visual separation or trimming."
title = "Add Borders" title = "Add Borders"
@@ -6071,6 +6185,26 @@ pdfTools = "Basic PDF Tools"
priority = "Priority Support" priority = "Priority Support"
title = "Feature" title = "Feature"
[plan.features]
allPdfOperations = "All PDF operations"
auditing = "Auditing"
communitySupport = "Community support"
customPdfMetadata = "Custom PDF metadata"
editingTextInPdfs = "Editing text in pdfs"
externalDatabase = "External Database"
googleDriveIntegration = "Google drive integration"
prometheusSupport = "Prometheus Support"
regularUpdates = "Regular updates"
saml = "SAML"
secureLoginSupport = "Secure Login Support"
selfHostedDeployment = "Self-hosted deployment"
sso = "SSO"
unlimitedUsers = "Unlimited users"
upToFiveUsers = "Up to 5 users"
upToFiveUsersLowercase = "up to 5 users"
usageTracking = "Usage tracking"
usersLimitedToSeats = "Users limited to seats"
[plan.free] [plan.free]
forever = "Forever free" forever = "Forever free"
highlight1 = "Limited Tool Usage Per week" highlight1 = "Limited Tool Usage Per week"
@@ -6079,6 +6213,22 @@ highlight3 = "Community support"
included = "Included" included = "Included"
name = "Free" name = "Free"
[plan.highlights]
advancedIntegrations = "Advanced integrations"
allBasicFeatures = "All basic features"
cancelAnytime = "Cancel anytime"
customPdfMetadata = "Custom PDF metadata"
editingTextInPdfsCaps = "Editing text in PDFs"
enterpriseFeatures = "Enterprise features (SAML, Auditing)"
perSeatLicensing = "Per-seat licensing"
saveWithAnnualBilling = "Save with annual billing"
selfHosted = "Self-hosted"
selfHostedOnInfrastructure = "Self-hosted on your infrastructure"
ssoOAuth = "SSO (OAuth2/OIDC)"
unlimitedUsers = "Unlimited users"
upToFiveUsers = "Up to 5 users"
usageTrackingPrometheus = "Usage tracking & Prometheus"
[plan.licenseWarning] [plan.licenseWarning]
body = "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly." body = "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly."
cta = "See plans" cta = "See plans"
@@ -6389,6 +6539,7 @@ allSessions = "All Sessions"
allTools = "Tools" allTools = "Tools"
automate = "Automate" automate = "Automate"
back = "Back" back = "Back"
backToAllTools = "Back to all tools"
certSign = "Certificate Sign" certSign = "Certificate Sign"
completedSessions = "Completed Sessions" completedSessions = "Completed Sessions"
completedTab = "Completed" completedTab = "Completed"
@@ -7060,6 +7211,9 @@ bullet3 = "Critical for approval workflows and legal chains of custody"
description = "The order you specify when creating the session determines who signs first." description = "The order you specify when creating the session determines who signs first."
title = "Signature Order" title = "Signature Order"
[settings]
close = "Close"
[settings.configuration] [settings.configuration]
advanced = "Advanced" advanced = "Advanced"
database = "Database" database = "Database"
@@ -7542,6 +7696,7 @@ sharedDescription = "All users can see and use these signatures."
sharedHeading = "Shared Signatures" sharedHeading = "Shared Signatures"
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers." tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
tempStorageTitle = "Temporary browser storage" tempStorageTitle = "Temporary browser storage"
use = "Use signature"
[sign.saved.status] [sign.saved.status]
saved = "Saved" saved = "Saved"
@@ -8122,9 +8277,18 @@ center = "Center"
left = "Left" left = "Left"
right = "Right" right = "Right"
[textInput]
clear = "Clear input"
[theme] [theme]
toggle = "Toggle Theme" toggle = "Toggle Theme"
[time.relative]
daysAgo = "{{count}}d ago"
hoursAgo = "{{count}}h ago"
justNow = "just now"
minutesAgo = "{{count}}m ago"
[timestampPdf] [timestampPdf]
completed = "PDF timestamped successfully" completed = "PDF timestamped successfully"
desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server." desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server."
@@ -8151,11 +8315,9 @@ label = "Select a TSA server"
[timestampPdf.steps] [timestampPdf.steps]
settings = "Settings" settings = "Settings"
[time.relative] [toast]
daysAgo = "{{count}}d ago" dismiss = "Dismiss"
hoursAgo = "{{count}}h ago" toggleDetails = "Toggle details"
justNow = "just now"
minutesAgo = "{{count}}m ago"
[tool] [tool]
endpointUnavailable = "This tool is unavailable on your server." endpointUnavailable = "This tool is unavailable on your server."
@@ -8243,6 +8405,9 @@ verification = "Verification"
noSearchResults = "No tools found" noSearchResults = "No tools found"
noTools = "No tools available" noTools = "No tools available"
[tooltip]
close = "Close tooltip"
[unlockPDFForms] [unlockPDFForms]
description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable." description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable."
filenamePrefix = "unlocked_forms" filenamePrefix = "unlocked_forms"
@@ -8269,6 +8434,7 @@ breakingChangesDefault = "This version contains breaking changes."
breakingChangesDetected = "Breaking Changes Detected" breakingChangesDetected = "Breaking Changes Detected"
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating." breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
close = "Close" close = "Close"
closeModal = "Close update modal"
current = "Current Version" current = "Current Version"
defaultRecommendation = "This update contains important fixes and improvements." defaultRecommendation = "This update contains important fixes and improvements."
downloadLatest = "Download Latest" downloadLatest = "Download Latest"
@@ -8520,6 +8686,9 @@ typeInsertText = "Insert Text"
typeReplaceText = "Replace Text" typeReplaceText = "Replace Text"
viewComment = "View comment" viewComment = "View comment"
[viewer.error]
noFileProvided = "Error: No file provided to viewer"
[viewer.formBar] [viewer.formBar]
apply = "Apply Changes" apply = "Apply Changes"
dismiss = "Dismiss" dismiss = "Dismiss"
@@ -8529,6 +8698,9 @@ title = "Form Fields"
unsavedBadge = "Unsaved" unsavedBadge = "Unsaved"
unsavedDesc = "You have unsaved changes" unsavedDesc = "You have unsaved changes"
[viewer.link]
delete = "Delete link"
[viewer.nonPdf] [viewer.nonPdf]
columnDefault = "Column {{index}}" columnDefault = "Column {{index}}"
convertToPdf = "Convert to PDF" convertToPdf = "Convert to PDF"
@@ -8544,6 +8716,19 @@ renderMarkdown = "Render markdown"
sortedBy = "Sorted by: {{column}}" sortedBy = "Sorted by: {{column}}"
textStats = "{{lines}} lines · {{size}}" textStats = "{{lines}} lines · {{size}}"
[viewer.redaction]
removeMark = "Remove this mark"
[viewer.search]
clear = "Clear search"
close = "Close search"
next = "Next result"
previous = "Previous result"
resultsOf = "of {{total}}"
[viewer.signature]
delete = "Delete signature"
[viewPdf] [viewPdf]
header = "View PDF" header = "View PDF"
tags = "view,read,annotate,text,image,highlight,edit" tags = "view,read,annotate,text,image,highlight,edit"
@@ -8792,6 +8977,37 @@ toggleSidebar = "Toggle Sidebar"
toggleTheme = "Toggle Theme" toggleTheme = "Toggle Theme"
viewer = "Viewer" viewer = "Viewer"
[workflow.participant]
accessExpired = "Your access to this document has expired."
certificateFile = "Certificate File"
certificateFilePlaceholder = "Select certificate file"
certificatePassword = "Certificate Password"
certificateType = "Certificate Type"
certTypeJks = "JKS Keystore"
certTypeP12 = "P12/PKCS12 Certificate"
certTypeServer = "Server Certificate (if available)"
completedDeclined = "You have declined this document."
completedSigned = "You have signed this document."
decline = "Decline"
downloadDocument = "Download Document"
dueDate = "Due Date: {{date}}"
errorTitle = "Error"
from = "From: {{name}}"
loadingSession = "Loading session..."
location = "Location"
locationPlaceholder = "e.g., San Francisco, CA"
pageNumber = "Page Number (optional)"
reason = "Reason"
reasonPlaceholder = "e.g., Document approval"
sessionNotFound = "Session not found or access denied."
signDocument = "Sign Document"
statusDeclined = "Declined"
statusNotified = "Notified"
statusPending = "Pending"
statusSigned = "Signed"
statusViewed = "Viewed"
submitSignature = "Submit Signature"
[workspace] [workspace]
title = "Workspace" title = "Workspace"
@@ -17,6 +17,7 @@
"height": 800, "height": 800,
"resizable": true, "resizable": true,
"fullscreen": false, "fullscreen": false,
"dragDropEnabled": false,
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature" "additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
} }
] ]
@@ -8,6 +8,7 @@ import {
Group, Group,
} from "@mantine/core"; } from "@mantine/core";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import ColorizeIcon from "@mui/icons-material/Colorize"; import ColorizeIcon from "@mui/icons-material/Colorize";
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do. // safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
@@ -33,6 +34,7 @@ export function ColorControl({
label, label,
disabled = false, disabled = false,
}: ColorControlProps) { }: ColorControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
// Buffer the colour locally so the picker stays responsive during drag. // Buffer the colour locally so the picker stays responsive during drag.
// Only propagate to the parent (which triggers expensive annotation updates) // Only propagate to the parent (which triggers expensive annotation updates)
@@ -111,7 +113,9 @@ export function ColorControl({
/> />
{supportsEyeDropper && ( {supportsEyeDropper && (
<Group justify="flex-end"> <Group justify="flex-end">
<Tooltip label="Pick colour from screen"> <Tooltip
label={t("color.eyeDropper.tooltip", "Pick colour from screen")}
>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool"; import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader"; import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
@@ -12,6 +13,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange, onImageChange,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
const [, setImageData] = useState<string | null>(null); const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => { const handleImageUpload = async (file: File | null) => {
@@ -57,9 +59,12 @@ export const ImageTool: React.FC<ImageToolProps> = ({
<ImageUploader <ImageUploader
onImageChange={handleImageUpload} onImageChange={handleImageUpload}
disabled={disabled} disabled={disabled}
label="Upload Image" label={t("image.upload.label", "Upload Image")}
placeholder="Select image file" placeholder={t("image.upload.placeholder", "Select image file")}
hint="Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility." hint={t(
"image.upload.hint",
"Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility.",
)}
/> />
</Stack> </Stack>
</BaseAnnotationTool> </BaseAnnotationTool>
@@ -134,7 +134,7 @@ const AddFileCard = ({
)} )}
</Button> </Button>
<Button <Button
aria-label="Upload" aria-label={t("addFileCard.upload", "Upload")}
style={{ style={{
backgroundColor: "var(--landing-button-bg)", backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)", color: "var(--landing-button-color)",
@@ -77,7 +77,7 @@ const EmptyFilesState: React.FC = () => {
onMouseLeave={() => setIsUploadHover(false)} onMouseLeave={() => setIsUploadHover(false)}
> >
<Button <Button
aria-label="Upload" aria-label={t("emptyFilesState.upload", "Upload")}
style={{ style={{
backgroundColor: "var(--bg-file-manager)", backgroundColor: "var(--bg-file-manager)",
color: "var(--landing-button-color)", color: "var(--landing-button-color)",
@@ -16,6 +16,7 @@ import {
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types"; import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService"; import { accountService } from "@app/services/accountService";
@@ -31,6 +32,7 @@ interface MFASetupSlideProps {
} }
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const { t } = useTranslation();
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>( const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
null, null,
); );
@@ -179,7 +181,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
{mfaLoading && !isReady && ( {mfaLoading && !isReady && (
<Group gap="sm"> <Group gap="sm">
<Loader size="sm" /> <Loader size="sm" />
<Text size="sm">Generating your QR code</Text> <Text size="sm">
{t("onboarding.mfa.qrCodeLoading", "Generating your QR code…")}
</Text>
</Group> </Group>
)} )}
@@ -189,7 +193,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
<Stack gap="sm"> <Stack gap="sm">
<TextInput <TextInput
id="mfa-setup-code" id="mfa-setup-code"
label="Authentication code" label={t(
"onboarding.mfa.authenticationCode",
"Authentication code",
)}
placeholder="123456" placeholder="123456"
value={mfaSetupCode} value={mfaSetupCode}
onChange={(event) => onChange={(event) =>
@@ -37,11 +37,20 @@ export default function SecurityCheckSlide({
</div> </div>
<Select <Select
placeholder="Confirm your role" placeholder={i18n.t(
"onboarding.securityCheck.rolePlaceholder",
"Confirm your role",
)}
value={selectedRole} value={selectedRole}
data={[ data={[
{ value: "admin", label: "Admin" }, {
{ value: "user", label: "User" }, value: "admin",
label: i18n.t("onboarding.securityCheck.roleAdmin", "Admin"),
},
{
value: "user",
label: i18n.t("onboarding.securityCheck.roleUser", "User"),
},
]} ]}
onChange={(value) => onChange={(value) =>
onRoleSelect((value as "admin" | "user") ?? null) onRoleSelect((value as "admin" | "user") ?? null)
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions } from "@app/contexts/FileContext"; import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { import {
useNavigationGuard, useNavigationGuard,
@@ -33,6 +34,7 @@ export interface PageEditorProps {
} }
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const { t } = useTranslation();
// Use split contexts to prevent re-renders // Use split contexts to prevent re-renders
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const { actions } = useFileActions(); const { actions } = useFileActions();
@@ -688,9 +690,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
<Text size="lg" c="dimmed"> <Text size="lg" c="dimmed">
📄 📄
</Text> </Text>
<Text c="dimmed">No PDF files loaded</Text> <Text c="dimmed">
{t("pageEditor.emptyState.title", "No PDF files loaded")}
</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Add files to start editing pages {t(
"pageEditor.emptyState.body",
"Add files to start editing pages",
)}
</Text> </Text>
</Stack> </Stack>
</Center> </Center>
@@ -6,6 +6,7 @@ import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight"; import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak"; import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
import { useTranslation } from "react-i18next";
interface PageEditorControlsProps { interface PageEditorControlsProps {
// Close/Reset functions // Close/Reset functions
@@ -51,6 +52,7 @@ const PageEditorControls = ({
displayDocument, displayDocument,
splitPositions, splitPositions,
}: PageEditorControlsProps) => { }: PageEditorControlsProps) => {
const { t } = useTranslation();
// Calculate split tooltip text using smart toggle logic // Calculate split tooltip text using smart toggle logic
const getSplitTooltip = () => { const getSplitTooltip = () => {
if (!splitPositions || !displayDocument || selectedPageIds.length === 0) { if (!splitPositions || !displayDocument || selectedPageIds.length === 0) {
@@ -131,7 +133,7 @@ const PageEditorControls = ({
}} }}
> >
{/* Undo/Redo */} {/* Undo/Redo */}
<Tooltip label="Undo"> <Tooltip label={t("pageEditor.toolbar.undo", "Undo")}>
<ActionIcon <ActionIcon
onClick={onUndo} onClick={onUndo}
disabled={!canUndo} disabled={!canUndo}
@@ -145,7 +147,7 @@ const PageEditorControls = ({
<UndoIcon /> <UndoIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Redo"> <Tooltip label={t("pageEditor.toolbar.redo", "Redo")}>
<ActionIcon <ActionIcon
onClick={onRedo} onClick={onRedo}
disabled={!canRedo} disabled={!canRedo}
@@ -170,7 +172,9 @@ const PageEditorControls = ({
/> />
{/* Page Operations */} {/* Page Operations */}
<Tooltip label="Rotate Selected Left"> <Tooltip
label={t("pageEditor.toolbar.rotateLeft", "Rotate Selected Left")}
>
<ActionIcon <ActionIcon
onClick={() => onRotate("left")} onClick={() => onRotate("left")}
disabled={selectedPageIds.length === 0} disabled={selectedPageIds.length === 0}
@@ -187,7 +191,9 @@ const PageEditorControls = ({
<RotateLeftIcon /> <RotateLeftIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Rotate Selected Right"> <Tooltip
label={t("pageEditor.toolbar.rotateRight", "Rotate Selected Right")}
>
<ActionIcon <ActionIcon
onClick={() => onRotate("right")} onClick={() => onRotate("right")}
disabled={selectedPageIds.length === 0} disabled={selectedPageIds.length === 0}
@@ -204,7 +210,7 @@ const PageEditorControls = ({
<RotateRightIcon /> <RotateRightIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Delete Selected"> <Tooltip label={t("pageEditor.toolbar.delete", "Delete Selected")}>
<ActionIcon <ActionIcon
onClick={onDelete} onClick={onDelete}
disabled={selectedPageIds.length === 0} disabled={selectedPageIds.length === 0}
@@ -26,7 +26,10 @@ const OperatorsSection = ({
className={classes.operatorChip} className={classes.operatorChip}
onClick={() => onInsertOperator("and")} onClick={() => onInsertOperator("and")}
disabled={!csvInput.trim()} disabled={!csvInput.trim()}
title="Combine selections (both conditions must be true)" title={t(
"bulkSelection.operators.and.title",
"Combine selections (both conditions must be true)",
)}
> >
<Text size="xs" fw={500}> <Text size="xs" fw={500}>
and and
@@ -38,7 +41,10 @@ const OperatorsSection = ({
className={classes.operatorChip} className={classes.operatorChip}
onClick={() => onInsertOperator("or")} onClick={() => onInsertOperator("or")}
disabled={!csvInput.trim()} disabled={!csvInput.trim()}
title="Add to selection (either condition can be true)" title={t(
"bulkSelection.operators.or.title",
"Add to selection (either condition can be true)",
)}
> >
<Text size="xs" fw={500}> <Text size="xs" fw={500}>
or or
@@ -50,7 +56,10 @@ const OperatorsSection = ({
className={classes.operatorChip} className={classes.operatorChip}
onClick={() => onInsertOperator("not")} onClick={() => onInsertOperator("not")}
disabled={!csvInput.trim()} disabled={!csvInput.trim()}
title="Exclude from selection" title={t(
"bulkSelection.operators.not.title",
"Exclude from selection",
)}
> >
<Text size="xs" fw={500}> <Text size="xs" fw={500}>
not not
@@ -64,7 +73,10 @@ const OperatorsSection = ({
variant="outline" variant="outline"
className={classes.operatorChip} className={classes.operatorChip}
onClick={() => onInsertOperator("even")} onClick={() => onInsertOperator("even")}
title="Select all even-numbered pages (2, 4, 6, 8...)" title={t(
"bulkSelection.operators.even.title",
"Select all even-numbered pages (2, 4, 6, 8...)",
)}
> >
<Text size="xs" fw={500}> <Text size="xs" fw={500}>
even even
@@ -75,7 +87,10 @@ const OperatorsSection = ({
variant="outline" variant="outline"
className={classes.operatorChip} className={classes.operatorChip}
onClick={() => onInsertOperator("odd")} onClick={() => onInsertOperator("odd")}
title="Select all odd-numbered pages (1, 3, 5, 7...)" title={t(
"bulkSelection.operators.odd.title",
"Select all odd-numbered pages (1, 3, 5, 7...)",
)}
> >
<Text size="xs" fw={500}> <Text size="xs" fw={500}>
odd odd
@@ -45,7 +45,9 @@ const PageSelectionInput = ({
height="1rem" height="1rem"
style={{ color: "var(--text-instruction)" }} style={{ color: "var(--text-instruction)" }}
/> />
<Text>Page Selection</Text> <Text>
{t("bulkSelection.pageSelection.title", "Page Selection")}
</Text>
</Flex> </Flex>
</Tooltip> </Tooltip>
{typeof advancedOpened === "boolean" && ( {typeof advancedOpened === "boolean" && (
@@ -7,6 +7,7 @@ import React, {
} from "react"; } from "react";
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core"; import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon"; import LocalIcon from "@app/components/shared/LocalIcon";
import { useConfigNavSections } from "@app/components/shared/config/configNavSections"; import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types"; import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
@@ -34,6 +35,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
opened, opened,
onClose, onClose,
}) => { }) => {
const { t } = useTranslation();
const [active, setActive] = useState<NavKey>("general"); const [active, setActive] = useState<NavKey>("general");
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -333,7 +335,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
ref={closeButtonRef} ref={closeButtonRef}
variant="subtle" variant="subtle"
onClick={handleClose} onClick={handleClose}
aria-label="Close" aria-label={t("settings.close", "Close")}
data-autofocus data-autofocus
> >
<LocalIcon icon="close-rounded" width={18} height={18} /> <LocalIcon icon="close-rounded" width={18} height={18} />
@@ -8,6 +8,7 @@ import {
Group, Group,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next";
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
@@ -71,6 +72,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
withinPortal = true, withinPortal = true,
zIndex = Z_INDEX_AUTOMATE_DROPDOWN, zIndex = Z_INDEX_AUTOMATE_DROPDOWN,
}) => { }) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const isMultiValue = Array.isArray(value); const isMultiValue = Array.isArray(value);
@@ -184,7 +186,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
}} }}
> >
<TextInput <TextInput
placeholder="Search..." placeholder={t("dropdownList.searchPlaceholder", "Search...")}
value={searchTerm} value={searchTerm}
onChange={handleSearchChange} onChange={handleSearchChange}
leftSection={<SearchIcon style={{ fontSize: "1rem" }} />} leftSection={<SearchIcon style={{ fontSize: "1rem" }} />}
@@ -98,8 +98,11 @@ export default function EditableSecretField({
variant="light" variant="light"
onClick={handleEdit} onClick={handleEdit}
disabled={disabled} disabled={disabled}
title="Edit" title={t("editableSecretField.edit", "Edit")}
aria-label="Edit secret value" aria-label={t(
"editableSecretField.editSecretValue",
"Edit secret value",
)}
> >
<LocalIcon icon="edit" width="1rem" height="1rem" /> <LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon> </ActionIcon>
@@ -111,7 +111,7 @@ const FileCard = ({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{onView && ( {onView && (
<Tooltip label="View in Viewer"> <Tooltip label={t("fileCard.viewInViewer", "View in Viewer")}>
<ActionIcon <ActionIcon
size="sm" size="sm"
variant="subtle" variant="subtle"
@@ -126,7 +126,9 @@ const FileCard = ({
</Tooltip> </Tooltip>
)} )}
{onEdit && ( {onEdit && (
<Tooltip label="Open in File Editor"> <Tooltip
label={t("fileCard.openInFileEditor", "Open in File Editor")}
>
<ActionIcon <ActionIcon
size="sm" size="sm"
variant="subtle" variant="subtle"
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core"; import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
@@ -28,6 +29,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
switchingTo, switchingTo,
viewOptionStyle, viewOptionStyle,
}) => { }) => {
const { t } = useTranslation();
return ( return (
<Menu trigger="click" position="bottom" width="30rem"> <Menu trigger="click" position="bottom" width="30rem">
<Menu.Target> <Menu.Target>
@@ -95,7 +97,10 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
</Text> </Text>
)} )}
{onFileRemove && ( {onFileRemove && (
<Tooltip label="Close file" withArrow> <Tooltip
label={t("fileDropdownMenu.closeFile", "Close file")}
withArrow
>
<ActionIcon <ActionIcon
component="div" component="div"
size="xs" size="xs"
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined"; import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import type { FileId } from "@app/types/file"; import type { FileId } from "@app/types/file";
@@ -141,6 +142,7 @@ export function FileItem({
onClick, onClick,
onEyeClick, onEyeClick,
}: FileItemProps) { }: FileItemProps) {
const { t } = useTranslation();
const ext = getFileExtension(name); const ext = getFileExtension(name);
const dateLabel = lastModified ? formatFileDate(lastModified) : ""; const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = ext ? ext.toUpperCase() : "File"; const typeLabel = ext ? ext.toUpperCase() : "File";
@@ -215,7 +217,11 @@ export function FileItem({
}} }}
tabIndex={-1} tabIndex={-1}
type="button" type="button"
aria-label={isViewedInViewer ? "Close viewer" : "Open in viewer"} aria-label={
isViewedInViewer
? t("fileSidebar.fileItem.closeViewer", "Close viewer")
: t("fileSidebar.fileItem.openInViewer", "Open in viewer")
}
> >
<VisibilityOutlinedIcon <VisibilityOutlinedIcon
className="file-sidebar-eye-open" className="file-sidebar-eye-open"
@@ -1,5 +1,6 @@
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core"; import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon"; import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = "info" | "warning"; type InfoBannerTone = "info" | "warning";
@@ -85,6 +86,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
closeIconColor, closeIconColor,
compact = false, compact = false,
}) => { }) => {
const { t } = useTranslation();
if (!show) { if (!show) {
return null; return null;
} }
@@ -192,7 +194,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
color={closeIconColor ? undefined : "gray"} color={closeIconColor ? undefined : "gray"}
size="sm" size="sm"
onClick={handleDismiss} onClick={handleDismiss}
aria-label="Dismiss" aria-label={t("infoBanner.dismiss", "Dismiss")}
style={closeIconColor ? { color: closeIconColor } : undefined} style={closeIconColor ? { color: closeIconColor } : undefined}
> >
<LocalIcon <LocalIcon
@@ -1,4 +1,5 @@
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon"; import LocalIcon from "@app/components/shared/LocalIcon";
import styles from "@app/components/shared/textInput/TextInput.module.css"; import styles from "@app/components/shared/textInput/TextInput.module.css";
@@ -63,6 +64,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
}, },
ref, ref,
) => { ) => {
const { t } = useTranslation();
const handleClear = () => { const handleClear = () => {
if (onClear) { if (onClear) {
onClear(); onClear();
@@ -112,7 +114,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
type="button" type="button"
className={styles.clearButton} className={styles.clearButton}
onClick={handleClear} onClick={handleClear}
aria-label="Clear input" aria-label={t("textInput.clear", "Clear input")}
> >
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" /> <LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button> </button>
@@ -6,6 +6,7 @@ import React, {
useCallback, useCallback,
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon"; import LocalIcon from "@app/components/shared/LocalIcon";
import { addEventListenerWithCleanup } from "@app/utils/genericUtils"; import { addEventListenerWithCleanup } from "@app/utils/genericUtils";
import { useTooltipPosition } from "@app/hooks/useTooltipPosition"; import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
@@ -68,6 +69,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
manualCloseOnly = false, manualCloseOnly = false,
showCloseButton = false, showCloseButton = false,
}) => { }) => {
const { t } = useTranslation();
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false); const [isPinned, setIsPinned] = useState(false);
const { tooltipLogo } = useLogoAssets(); const { tooltipLogo } = useLogoAssets();
@@ -401,8 +403,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
setIsPinned(false); setIsPinned(false);
setOpen(false); setOpen(false);
}} }}
title="Close tooltip" title={t("tooltip.close", "Close tooltip")}
aria-label="Close tooltip" aria-label={t("tooltip.close", "Close tooltip")}
> >
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" /> <LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button> </button>
@@ -259,7 +259,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
onClick={onClose} onClick={onClose}
size="lg" size="lg"
variant="subtle" variant="subtle"
aria-label="Close update modal" aria-label={t("update.closeModal", "Close update modal")}
/> />
)} )}
</Group> </Group>
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { ActionIcon, Slider } from "@mantine/core"; import { ActionIcon, Slider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useViewer } from "@app/contexts/ViewerContext"; import { useViewer } from "@app/contexts/ViewerContext";
import { useNavigationState } from "@app/contexts/NavigationContext"; import { useNavigationState } from "@app/contexts/NavigationContext";
import ZoomInIcon from "@mui/icons-material/ZoomIn"; import ZoomInIcon from "@mui/icons-material/ZoomIn";
@@ -9,6 +10,7 @@ import ZoomOutIcon from "@mui/icons-material/ZoomOut";
* Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer". * Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer".
*/ */
export function ViewerInlineControls() { export function ViewerInlineControls() {
const { t } = useTranslation();
const { workbench } = useNavigationState(); const { workbench } = useNavigationState();
const viewer = useViewer(); const viewer = useViewer();
@@ -39,7 +41,7 @@ export function ViewerInlineControls() {
radius="md" radius="md"
className="workbench-bar-action-icon" className="workbench-bar-action-icon"
onClick={() => viewer.zoomActions.zoomOut()} onClick={() => viewer.zoomActions.zoomOut()}
aria-label="Zoom out" aria-label={t("viewer.zoomOut", "Zoom out")}
> >
<ZoomOutIcon sx={{ fontSize: "1rem" }} /> <ZoomOutIcon sx={{ fontSize: "1rem" }} />
</ActionIcon> </ActionIcon>
@@ -68,7 +70,7 @@ export function ViewerInlineControls() {
radius="md" radius="md"
className="workbench-bar-action-icon" className="workbench-bar-action-icon"
onClick={() => viewer.zoomActions.zoomIn()} onClick={() => viewer.zoomActions.zoomIn()}
aria-label="Zoom in" aria-label={t("viewer.zoomIn", "Zoom in")}
> >
<ZoomInIcon sx={{ fontSize: "1rem" }} /> <ZoomInIcon sx={{ fontSize: "1rem" }} />
</ActionIcon> </ActionIcon>
@@ -15,6 +15,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { ActionIcon, Divider } from "@mantine/core"; import { ActionIcon, Divider } from "@mantine/core";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { import {
useNavigationState, useNavigationState,
@@ -37,6 +38,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
setActiveButton, setActiveButton,
tooltipPosition = "right", tooltipPosition = "right",
}) => { }) => {
const { t } = useTranslation();
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } =
useToolWorkflow(); useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState(); const { hasUnsavedChanges } = useNavigationState();
@@ -158,7 +160,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
<div className="current-tool-content"> <div className="current-tool-content">
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<Tooltip <Tooltip
content={isBackHover ? "Back to all tools" : indicatorTool.name} content={
isBackHover
? t("quickAccess.backToAllTools", "Back to all tools")
: indicatorTool.name
}
position={tooltipPosition} position={tooltipPosition}
arrow arrow
maxWidth={140} maxWidth={140}
@@ -183,7 +189,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
onMouseEnter={() => setIsBackHover(true)} onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)} onMouseLeave={() => setIsBackHover(false)}
aria-label={ aria-label={
isBackHover ? "Back to all tools" : indicatorTool.name isBackHover
? t("quickAccess.backToAllTools", "Back to all tools")
: indicatorTool.name
} }
style={{ style={{
backgroundColor: isBackHover backgroundColor: isBackHover
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useToast } from "@app/components/toast/ToastContext"; import { useToast } from "@app/components/toast/ToastContext";
import { ToastInstance, ToastLocation } from "@app/components/toast/types"; import { ToastInstance, ToastLocation } from "@app/components/toast/types";
import { LocalIcon } from "@app/components/shared/LocalIcon"; import { LocalIcon } from "@app/components/shared/LocalIcon";
@@ -38,6 +39,7 @@ function getDefaultIconName(t: ToastInstance): string {
} }
export default function ToastRenderer() { export default function ToastRenderer() {
const { t: translate } = useTranslation();
const { toasts, dismiss } = useToast(); const { toasts, dismiss } = useToast();
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>( const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>(
@@ -88,7 +90,10 @@ export default function ToastRenderer() {
<div className="toast-controls"> <div className="toast-controls">
{t.expandable && ( {t.expandable && (
<button <button
aria-label="Toggle details" aria-label={translate(
"toast.toggleDetails",
"Toggle details",
)}
onClick={() => { onClick={() => {
const evt = new CustomEvent("toast:toggle", { const evt = new CustomEvent("toast:toggle", {
detail: { id: t.id }, detail: { id: t.id },
@@ -101,7 +106,7 @@ export default function ToastRenderer() {
</button> </button>
)} )}
<button <button
aria-label="Dismiss" aria-label={translate("toast.dismiss", "Dismiss")}
onClick={() => dismiss(t.id)} onClick={() => dismiss(t.id)}
className="toast-button" className="toast-button"
> >
@@ -321,7 +321,7 @@ export default function AutomationEntry({
return shouldShowTooltip ? ( return shouldShowTooltip ? (
<Tooltip <Tooltip
content={createTooltipContent()} content={createTooltipContent()}
position="right" position="left"
arrow={true} arrow={true}
delay={500} delay={500}
> >
@@ -99,11 +99,14 @@ const CompressSettings = ({
{parameters.compressionMethod === "filesize" && ( {parameters.compressionMethod === "filesize" && (
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
Desired File Size {t("compress.settings.desiredSize", "Desired File Size")}
</Text> </Text>
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}> <div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
<NumberInput <NumberInput
placeholder="Enter size" placeholder={t(
"compress.settings.desiredSizePlaceholder",
"Enter size",
)}
value={parameters.fileSizeValue} value={parameters.fileSizeValue}
onChange={(value) => onChange={(value) =>
onParameterChange("fileSizeValue", value?.toString() || "") onParameterChange("fileSizeValue", value?.toString() || "")
@@ -139,7 +139,9 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Loader size="xs" /> <Loader size="xs" />
<Text size="sm">Loading available languages...</Text> <Text size="sm">
{t("ocr.languagePicker.loading", "Loading available languages...")}
</Text>
</div> </div>
); );
} }
@@ -75,7 +75,7 @@ export default function PageLayoutMarginsBordersSettings({
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t("pageLayout.top", "Top Margin")} label={t("pageLayout.top", "Top Margin")}
placeholder="Enter top margin" placeholder={t("pageLayout.margins.topPlaceholder", "Enter top margin")}
value={parameters.topMargin} value={parameters.topMargin}
onChange={(v) => onParameterChange("topMargin", Number(v))} onChange={(v) => onParameterChange("topMargin", Number(v))}
min={0} min={0}
@@ -85,7 +85,10 @@ export default function PageLayoutMarginsBordersSettings({
/> />
<NumberInput <NumberInput
label={t("pageLayout.bottom", "Bottom Margin")} label={t("pageLayout.bottom", "Bottom Margin")}
placeholder="Enter bottom margin" placeholder={t(
"pageLayout.margins.bottomPlaceholder",
"Enter bottom margin",
)}
value={parameters.bottomMargin} value={parameters.bottomMargin}
onChange={(v) => onParameterChange("bottomMargin", Number(v))} onChange={(v) => onParameterChange("bottomMargin", Number(v))}
min={0} min={0}
@@ -95,7 +98,10 @@ export default function PageLayoutMarginsBordersSettings({
/> />
<NumberInput <NumberInput
label={t("pageLayout.left", "Left Margin")} label={t("pageLayout.left", "Left Margin")}
placeholder="Enter left margin" placeholder={t(
"pageLayout.margins.leftPlaceholder",
"Enter left margin",
)}
value={parameters.leftMargin} value={parameters.leftMargin}
onChange={(v) => onParameterChange("leftMargin", Number(v))} onChange={(v) => onParameterChange("leftMargin", Number(v))}
min={0} min={0}
@@ -104,7 +110,10 @@ export default function PageLayoutMarginsBordersSettings({
/> />
<NumberInput <NumberInput
label={t("pageLayout.right", "Right Margin")} label={t("pageLayout.right", "Right Margin")}
placeholder="Enter right margin" placeholder={t(
"pageLayout.margins.rightPlaceholder",
"Enter right margin",
)}
value={parameters.rightMargin} value={parameters.rightMargin}
onChange={(v) => onParameterChange("rightMargin", Number(v))} onChange={(v) => onParameterChange("rightMargin", Number(v))}
min={0} min={0}
@@ -114,7 +123,10 @@ export default function PageLayoutMarginsBordersSettings({
/> />
<NumberInput <NumberInput
label={t("pageLayout.innerMargin", "Inner Margin")} label={t("pageLayout.innerMargin", "Inner Margin")}
placeholder="Enter inner margin" placeholder={t(
"pageLayout.margins.innerPlaceholder",
"Enter inner margin",
)}
value={parameters.innerMargin} value={parameters.innerMargin}
onChange={(v) => onParameterChange("innerMargin", Number(v))} onChange={(v) => onParameterChange("innerMargin", Number(v))}
min={0} min={0}
@@ -137,7 +149,10 @@ export default function PageLayoutMarginsBordersSettings({
{parameters.addBorder && ( {parameters.addBorder && (
<NumberInput <NumberInput
label={t("pageLayout.borderWidth", "Border Thickness")} label={t("pageLayout.borderWidth", "Border Thickness")}
placeholder="Enter border thickness" placeholder={t(
"pageLayout.margins.borderThicknessPlaceholder",
"Enter border thickness",
)}
value={parameters.borderWidth} value={parameters.borderWidth}
onChange={(v) => onParameterChange("borderWidth", Number(v))} onChange={(v) => onParameterChange("borderWidth", Number(v))}
min={1} min={1}
@@ -80,7 +80,7 @@ export default function PageLayoutSettings({
<> <>
<NumberInput <NumberInput
label={t("pageLayout.rows", "Rows")} label={t("pageLayout.rows", "Rows")}
placeholder="Enter rows" placeholder={t("pageLayout.rowsPlaceholder", "Enter rows")}
value={parameters.rows} value={parameters.rows}
onChange={(v) => onParameterChange("rows", Number(v))} onChange={(v) => onParameterChange("rows", Number(v))}
min={1} min={1}
@@ -90,7 +90,7 @@ export default function PageLayoutSettings({
<NumberInput <NumberInput
label={t("pageLayout.cols", "Columns")} label={t("pageLayout.cols", "Columns")}
placeholder="Enter columns" placeholder={t("pageLayout.colsPlaceholder", "Enter columns")}
value={parameters.cols} value={parameters.cols}
onChange={(v) => onParameterChange("cols", Number(v))} onChange={(v) => onParameterChange("cols", Number(v))}
min={1} min={1}
@@ -353,7 +353,7 @@ export const SavedSignaturesSection = ({
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="blue" color="blue"
aria-label="Use signature" aria-label={t("sign.saved.use", "Use signature")}
onClick={() => onUseSignature(activePersonalSignature)} onClick={() => onUseSignature(activePersonalSignature)}
disabled={disabled} disabled={disabled}
> >
@@ -480,7 +480,7 @@ export const SavedSignaturesSection = ({
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="blue" color="blue"
aria-label="Use signature" aria-label={t("sign.saved.use", "Use signature")}
onClick={() => onUseSignature(activeSharedSignature)} onClick={() => onUseSignature(activeSharedSignature)}
disabled={disabled} disabled={disabled}
> >
@@ -618,7 +618,7 @@ export const SavedSignaturesSection = ({
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="blue" color="blue"
aria-label="Use signature" aria-label={t("sign.saved.use", "Use signature")}
onClick={() => onClick={() =>
onUseSignature(activeLocalStorageSignature) onUseSignature(activeLocalStorageSignature)
} }
@@ -32,16 +32,16 @@ export const usePageSelectionTips = (): TooltipContent => {
), ),
bullets: [ bullets: [
t( t(
"bulkSelection.operators.and", "bulkSelection.operators.descriptions.and",
'AND: & or "and" require both conditions (e.g., 1-50 & even)', 'AND: & or "and" - require both conditions (e.g., 1-50 & even)',
), ),
t( t(
"bulkSelection.operators.comma", "bulkSelection.operators.descriptions.comma",
"Comma: , or | combine selections (e.g., 1-10, 20)", "Comma: , or | - combine selections (e.g., 1-10, 20)",
), ),
t( t(
"bulkSelection.operators.not", "bulkSelection.operators.descriptions.not",
'NOT: ! or "not" exclude pages (e.g., 3n & not 30)', 'NOT: ! or "not" - exclude pages (e.g., 3n & not 30)',
), ),
], ],
}, },
@@ -1176,7 +1176,12 @@ const EmbedPdfViewerContent = ({
{!effectiveFile ? ( {!effectiveFile ? (
<Center style={{ flex: 1 }}> <Center style={{ flex: 1 }}>
<Text c="red">Error: No file provided to viewer</Text> <Text c="red">
{t(
"viewer.error.noFileProvided",
"Error: No file provided to viewer",
)}
</Text>
</Center> </Center>
) : isCurrentFileEncrypted ? ( ) : isCurrentFileEncrypted ? (
<Center style={{ flex: 1 }}> <Center style={{ flex: 1 }}>
@@ -1,4 +1,5 @@
import React, { useCallback, useState, useMemo, useRef } from "react"; import React, { useCallback, useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useDocumentState } from "@embedpdf/core/react"; import { useDocumentState } from "@embedpdf/core/react";
import { useScroll } from "@embedpdf/plugin-scroll/react"; import { useScroll } from "@embedpdf/plugin-scroll/react";
import { useAnnotation } from "@embedpdf/plugin-annotation/react"; import { useAnnotation } from "@embedpdf/plugin-annotation/react";
@@ -143,6 +144,7 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
}) => { }) => {
const { t } = useTranslation();
const centerX = const centerX =
(annotationLink.rect.origin.x + annotationLink.rect.size.width / 2) * (annotationLink.rect.origin.x + annotationLink.rect.size.width / 2) *
scale; scale;
@@ -170,8 +172,8 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
e.stopPropagation(); e.stopPropagation();
onDelete(annotationLink); onDelete(annotationLink);
}} }}
aria-label="Delete link" aria-label={t("viewer.link.delete", "Delete link")}
title="Delete link" title={t("viewer.link.delete", "Delete link")}
> >
<TrashIcon /> <TrashIcon />
</button> </button>
@@ -6,6 +6,7 @@ import {
forwardRef, forwardRef,
useRef, useRef,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { createPluginRegistration } from "@embedpdf/core"; import { createPluginRegistration } from "@embedpdf/core";
import type { PluginRegistry } from "@embedpdf/core"; import type { PluginRegistry } from "@embedpdf/core";
import { EmbedPDF } from "@embedpdf/core/react"; import { EmbedPDF } from "@embedpdf/core/react";
@@ -157,6 +158,7 @@ export const LocalEmbedPDFWithAnnotations = forwardRef<
}, },
ref, ref,
) => { ) => {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null); const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const annotationApiRef = useRef<any>(null); const annotationApiRef = useRef<any>(null);
const zoomApiRef = useRef<any>(null); const zoomApiRef = useRef<any>(null);
@@ -653,7 +655,10 @@ export const LocalEmbedPDFWithAnnotations = forwardRef<
), ),
); );
}} }}
aria-label="Delete signature" aria-label={t(
"viewer.signature.delete",
"Delete signature",
)}
> >
<CloseIcon <CloseIcon
style={{ fontSize: "0.8rem" }} style={{ fontSize: "0.8rem" }}
@@ -139,7 +139,7 @@ function RedactionSelectionMenuInner({
}} }}
> >
<Group gap="sm" wrap="nowrap" justify="center"> <Group gap="sm" wrap="nowrap" justify="center">
<Tooltip label="Remove this mark"> <Tooltip label={t("viewer.redaction.removeMark", "Remove this mark")}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
@@ -203,7 +203,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
variant="subtle" variant="subtle"
size="sm" size="sm"
onClick={handleCloseClick} onClick={handleCloseClick}
aria-label="Close search" aria-label={t("viewer.search.close", "Close search")}
> >
<LocalIcon icon="close" width="1rem" height="1rem" /> <LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon> </ActionIcon>
@@ -227,7 +227,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
onClick={handleClearSearch} onClick={handleClearSearch}
aria-label="Clear search" aria-label={t("viewer.search.clear", "Clear search")}
> >
<LocalIcon icon="close" width="0.875rem" height="0.875rem" /> <LocalIcon icon="close" width="0.875rem" height="0.875rem" />
</ActionIcon> </ActionIcon>
@@ -267,7 +267,9 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
disabled={!resultInfo || resultInfo.totalResults === 0} disabled={!resultInfo || resultInfo.totalResults === 0}
/> />
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
of {resultInfo?.totalResults || 0} {t("viewer.search.resultsOf", "of {{total}}", {
total: resultInfo?.totalResults || 0,
})}
</Text> </Text>
</Group> </Group>
@@ -277,7 +279,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
size="sm" size="sm"
onClick={handlePrevious} onClick={handlePrevious}
disabled={!resultInfo || resultInfo.currentIndex <= 1} disabled={!resultInfo || resultInfo.currentIndex <= 1}
aria-label="Previous result" aria-label={t("viewer.search.previous", "Previous result")}
> >
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" /> <LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
</ActionIcon> </ActionIcon>
@@ -288,7 +290,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
disabled={ disabled={
!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults !resultInfo || resultInfo.currentIndex >= resultInfo.totalResults
} }
aria-label="Next result" aria-label={t("viewer.search.next", "Next result")}
> >
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" /> <LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
</ActionIcon> </ActionIcon>
@@ -34,7 +34,7 @@ interface FormFieldSidebarProps {
} }
export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) { export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
useTranslation(); const { t } = useTranslation();
const { state, setValue, setActiveField } = useFormFill(); const { state, setValue, setActiveField } = useFormFill();
const { fields, activeFieldName, loading } = state; const { fields, activeFieldName, loading } = state;
const activeFieldRef = useRef<HTMLDivElement>(null); const activeFieldRef = useRef<HTMLDivElement>(null);
@@ -119,7 +119,7 @@ export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
variant="subtle" variant="subtle"
size="sm" size="sm"
onClick={onToggle} onClick={onToggle}
aria-label="Close sidebar" aria-label={t("formFill.sidebar.close", "Close sidebar")}
> >
<CloseIcon sx={{ fontSize: 16 }} /> <CloseIcon sx={{ fontSize: 16 }} />
</ActionIcon> </ActionIcon>
@@ -27,6 +27,7 @@ import {
Tooltip, Tooltip,
ActionIcon, ActionIcon,
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { import {
useFormFill, useFormFill,
useAllFormValues, useAllFormValues,
@@ -120,6 +121,7 @@ const _MODE_TABS: ModeTabDef[] = [
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const FormFill = (_props: BaseToolProps) => { const FormFill = (_props: BaseToolProps) => {
const { t } = useTranslation();
const { selectedTool } = useNavigation(); const { selectedTool } = useNavigation();
const { selectors, state: fileState } = useFileState(); const { selectors, state: fileState } = useFileState();
@@ -488,7 +490,10 @@ const FormFill = (_props: BaseToolProps) => {
{/* Flatten toggle */} {/* Flatten toggle */}
<Switch <Switch
label="Flatten after filling" label={t(
"formFill.flattenAfterFilling",
"Flatten after filling",
)}
checked={flatten} checked={flatten}
onChange={(e) => setFlatten(e.currentTarget.checked)} onChange={(e) => setFlatten(e.currentTarget.checked)}
size="xs" size="xs"
@@ -507,15 +512,22 @@ const FormFill = (_props: BaseToolProps) => {
loading={saving} loading={saving}
disabled={!formState.isDirty && !flattenChanged} disabled={!formState.isDirty && !flattenChanged}
> >
Save {t("formFill.save", "Save")}
</Button> </Button>
<Tooltip label="Re-scan fields" withArrow position="bottom"> <Tooltip
label={t("formFill.rescanFields", "Re-scan fields")}
withArrow
position="bottom"
>
<ActionIcon <ActionIcon
variant="light" variant="light"
size="md" size="md"
onClick={handleRefresh} onClick={handleRefresh}
aria-label="Re-scan form fields" aria-label={t(
"formFill.rescanFormFields",
"Re-scan form fields",
)}
> >
<RefreshIcon sx={{ fontSize: 16 }} /> <RefreshIcon sx={{ fontSize: 16 }} />
</ActionIcon> </ActionIcon>
@@ -39,13 +39,15 @@ export function OverviewHeader() {
</Text> </Text>
{user?.email && ( {user?.email && (
<Text size="xs" c="dimmed" mt="0.25rem"> <Text size="xs" c="dimmed" mt="0.25rem">
Signed in as: {user.email} {t("account.overview.signedInAs", "Signed in as: {{email}}", {
email: user.email,
})}
</Text> </Text>
)} )}
</div> </div>
{user && ( {user && (
<Button color="red" variant="filled" onClick={handleLogout}> <Button color="red" variant="filled" onClick={handleLogout}>
Log out {t("account.overview.logOut", "Log out")}
</Button> </Button>
)} )}
</div> </div>
@@ -809,7 +809,10 @@ export default function AdminAdvancedSection() {
}, },
}) })
} }
placeholder="Default: java.io.tmpdir/stirling-pdf" placeholder={t(
"admin.settings.advanced.tempFileManagement.baseTmpDir.placeholder",
"Default: java.io.tmpdir/stirling-pdf",
)}
disabled={!loginEnabled} disabled={!loginEnabled}
/> />
</div> </div>
@@ -834,7 +837,10 @@ export default function AdminAdvancedSection() {
}, },
}) })
} }
placeholder="Default: baseTmpDir/libreoffice" placeholder={t(
"admin.settings.advanced.tempFileManagement.libreofficeDir.placeholder",
"Default: baseTmpDir/libreoffice",
)}
disabled={!loginEnabled} disabled={!loginEnabled}
/> />
</div> </div>
@@ -859,7 +865,10 @@ export default function AdminAdvancedSection() {
}, },
}) })
} }
placeholder="System temp directory path" placeholder={t(
"admin.settings.advanced.tempFileManagement.systemTempDir.placeholder",
"System temp directory path",
)}
disabled={!loginEnabled} disabled={!loginEnabled}
/> />
</div> </div>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
NumberInput, NumberInput,
@@ -52,6 +53,7 @@ interface DatabaseSettingsData {
export default function AdminDatabaseSection() { export default function AdminDatabaseSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired(); useLoginRequired();
const { const {
@@ -462,7 +464,16 @@ export default function AdminDatabaseSection() {
)} )}
</Text> </Text>
</div> </div>
<Badge color="grape" size="lg"> <Badge
color="grape"
size="lg"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
ENTERPRISE ENTERPRISE
</Badge> </Badge>
</Group> </Group>
@@ -698,7 +709,10 @@ export default function AdminDatabaseSection() {
onChange={(value) => onChange={(value) =>
setSettings({ ...settings, password: value }) setSettings({ ...settings, password: value })
} }
placeholder="Enter database password" placeholder={t(
"admin.settings.database.password.placeholder",
"Enter database password",
)}
disabled={!loginEnabled} disabled={!loginEnabled}
/> />
</div> </div>
@@ -296,7 +296,10 @@ export default function AdminEndpointsSection() {
}))} }))}
searchable searchable
clearable clearable
placeholder="Select endpoints to disable" placeholder={t(
"admin.settings.endpoints.toRemove.placeholder",
"Select endpoints to disable",
)}
comboboxProps={{ zIndex: 1400 }} comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled} disabled={!loginEnabled}
/> />
@@ -330,7 +333,10 @@ export default function AdminEndpointsSection() {
}))} }))}
searchable searchable
clearable clearable
placeholder="Select groups to disable" placeholder={t(
"admin.settings.endpoints.groupsToRemove.placeholder",
"Select groups to disable",
)}
comboboxProps={{ zIndex: 1400 }} comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled} disabled={!loginEnabled}
/> />
@@ -286,7 +286,10 @@ export default function AdminMailSection() {
onChange={(value) => onChange={(value) =>
setSettings({ ...settings, password: value }) setSettings({ ...settings, password: value })
} }
placeholder="Enter SMTP password" placeholder={t(
"admin.settings.mail.password.placeholder",
"Enter SMTP password",
)}
/> />
</div> </div>
@@ -183,8 +183,14 @@ const AdminPlanSection: React.FC = () => {
if (!plans || plans.length === 0) { if (!plans || plans.length === 0) {
return ( return (
<Alert color="yellow" title="No data available"> <Alert
Plans data is not available at the moment. color="yellow"
title={t("admin.settings.plan.noData.title", "No data available")}
>
{t(
"admin.settings.plan.noData.message",
"Plans data is not available at the moment.",
)}
</Alert> </Alert>
); );
} }
@@ -1,5 +1,5 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { import {
TextInput, TextInput,
Switch, Switch,
@@ -132,17 +132,29 @@ export default function AdminPremiumSection() {
</List.Item> </List.Item>
<List.Item> <List.Item>
<Text size="sm" component="span"> <Text size="sm" component="span">
<strong>Custom Metadata</strong> (PRO) - General <Trans
i18nKey="admin.settings.premium.movedFeatures.customMetadata"
defaults="<0>Custom Metadata</0> (PRO) - General"
components={[<strong />]}
/>
</Text> </Text>
</List.Item> </List.Item>
<List.Item> <List.Item>
<Text size="sm" component="span"> <Text size="sm" component="span">
<strong>Audit Logging</strong> (ENTERPRISE) - Security <Trans
i18nKey="admin.settings.premium.movedFeatures.auditLogging"
defaults="<0>Audit Logging</0> (ENTERPRISE) - Security"
components={[<strong />]}
/>
</Text> </Text>
</List.Item> </List.Item>
<List.Item> <List.Item>
<Text size="sm" component="span"> <Text size="sm" component="span">
<strong>Database Configuration</strong> (ENTERPRISE) - Database <Trans
i18nKey="admin.settings.premium.movedFeatures.databaseConfiguration"
defaults="<0>Database Configuration</0> (ENTERPRISE) - Database"
components={[<strong />]}
/>
</Text> </Text>
</List.Item> </List.Item>
</List> </List>
@@ -1,4 +1,5 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
NumberInput, NumberInput,
@@ -67,6 +68,7 @@ interface SecuritySettingsData {
export default function AdminSecuritySection() { export default function AdminSecuritySection() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled } = useLoginRequired(); const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { const {
restartModalOpened, restartModalOpened,
@@ -817,7 +819,16 @@ export default function AdminSecuritySection() {
<Text fw={600} size="sm"> <Text fw={600} size="sm">
{t("admin.settings.security.audit.label", "Audit Logging")} {t("admin.settings.security.audit.label", "Audit Logging")}
</Text> </Text>
<Badge color="grape" size="sm"> <Badge
color="grape"
size="sm"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
ENTERPRISE ENTERPRISE
</Badge> </Badge>
</Group> </Group>
@@ -11,7 +11,10 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast"; import { alert } from "@app/components/toast";
import { LicenseInfo, mapLicenseToTier } from "@app/services/licenseService"; import { LicenseInfo, mapLicenseToTier } from "@app/services/licenseService";
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants"; import {
usePlanFeatures,
usePlanHighlights,
} from "@app/constants/planConstants";
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable"; import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal"; import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection"; import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
@@ -32,6 +35,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
currentLicenseInfo, currentLicenseInfo,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const planFeatures = usePlanFeatures();
const planHighlights = usePlanHighlights();
const [showComparison, setShowComparison] = useState(false); const [showComparison, setShowComparison] = useState(false);
// Static checkout modal state // Static checkout modal state
@@ -88,8 +93,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
price: 0, price: 0,
currency: "£", currency: "£",
period: "", period: "",
highlights: PLAN_HIGHLIGHTS.FREE, highlights: planHighlights.FREE,
features: PLAN_FEATURES.FREE, features: planFeatures.FREE,
maxUsers: 5, maxUsers: 5,
}, },
{ {
@@ -99,8 +104,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
currency: "", currency: "",
period: "", period: "",
popular: false, popular: false,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY, highlights: planHighlights.SERVER_MONTHLY,
features: PLAN_FEATURES.SERVER, features: planFeatures.SERVER,
maxUsers: "Unlimited users", maxUsers: "Unlimited users",
}, },
{ {
@@ -109,8 +114,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
price: 0, price: 0,
currency: "", currency: "",
period: "", period: "",
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY, highlights: planHighlights.ENTERPRISE_MONTHLY,
features: PLAN_FEATURES.ENTERPRISE, features: planFeatures.ENTERPRISE,
maxUsers: "Custom", maxUsers: "Custom",
}, },
]; ];
@@ -185,14 +185,20 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
return ( return (
<Stack align="center" justify="center" p="xl"> <Stack align="center" justify="center" p="xl">
<Loader size="lg" /> <Loader size="lg" />
<Text c="dimmed">Loading session...</Text> <Text c="dimmed">
{t("workflow.participant.loadingSession", "Loading session...")}
</Text>
</Stack> </Stack>
); );
} }
if (error) { if (error) {
return ( return (
<Alert icon={<InfoIcon fontSize="small" />} color="red" title="Error"> <Alert
icon={<InfoIcon fontSize="small" />}
color="red"
title={t("workflow.participant.errorTitle", "Error")}
>
{error} {error}
</Alert> </Alert>
); );
@@ -201,7 +207,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
if (!session || !participant) { if (!session || !participant) {
return ( return (
<Alert icon={<InfoIcon fontSize="small" />} color="orange"> <Alert icon={<InfoIcon fontSize="small" />} color="orange">
Session not found or access denied. {t(
"workflow.participant.sessionNotFound",
"Session not found or access denied.",
)}
</Alert> </Alert>
); );
} }
@@ -209,15 +218,35 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case "SIGNED": case "SIGNED":
return <Badge color="green">Signed</Badge>; return (
<Badge color="green">
{t("workflow.participant.statusSigned", "Signed")}
</Badge>
);
case "DECLINED": case "DECLINED":
return <Badge color="red">Declined</Badge>; return (
<Badge color="red">
{t("workflow.participant.statusDeclined", "Declined")}
</Badge>
);
case "VIEWED": case "VIEWED":
return <Badge color="blue">Viewed</Badge>; return (
<Badge color="blue">
{t("workflow.participant.statusViewed", "Viewed")}
</Badge>
);
case "NOTIFIED": case "NOTIFIED":
return <Badge color="yellow">Notified</Badge>; return (
<Badge color="yellow">
{t("workflow.participant.statusNotified", "Notified")}
</Badge>
);
case "PENDING": case "PENDING":
return <Badge color="gray">Pending</Badge>; return (
<Badge color="gray">
{t("workflow.participant.statusPending", "Pending")}
</Badge>
);
default: default:
return <Badge>{status}</Badge>; return <Badge>{status}</Badge>;
} }
@@ -254,7 +283,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{session.documentName} {session.documentName}
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
From: {session.ownerUsername} {t("workflow.participant.from", "From: {{name}}", {
name: session.ownerUsername,
})}
</Text> </Text>
</div> </div>
{getStatusBadge(participant.status)} {getStatusBadge(participant.status)}
@@ -272,7 +303,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{session.dueDate && ( {session.dueDate && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Due Date: {session.dueDate} {t("workflow.participant.dueDate", "Due Date: {{date}}", {
date: session.dueDate,
})}
</Text> </Text>
)} )}
@@ -283,7 +316,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
onClick={() => downloadDocument(token)} onClick={() => downloadDocument(token)}
variant="light" variant="light"
> >
Download Document {t("workflow.participant.downloadDocument", "Download Document")}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -293,17 +326,35 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
<Card shadow="sm" padding="md" radius="md" withBorder> <Card shadow="sm" padding="md" radius="md" withBorder>
<Stack gap="md"> <Stack gap="md">
<Text fw={500} size="lg"> <Text fw={500} size="lg">
Sign Document {t("workflow.participant.signDocument", "Sign Document")}
</Text> </Text>
<Select <Select
label="Certificate Type" label={t(
"workflow.participant.certificateType",
"Certificate Type",
)}
value={certType} value={certType}
onChange={(value) => setCertType(value || "P12")} onChange={(value) => setCertType(value || "P12")}
data={[ data={[
{ value: "P12", label: "P12/PKCS12 Certificate" }, {
{ value: "JKS", label: "JKS Keystore" }, value: "P12",
{ value: "SERVER", label: "Server Certificate (if available)" }, label: t(
"workflow.participant.certTypeP12",
"P12/PKCS12 Certificate",
),
},
{
value: "JKS",
label: t("workflow.participant.certTypeJks", "JKS Keystore"),
},
{
value: "SERVER",
label: t(
"workflow.participant.certTypeServer",
"Server Certificate (if available)",
),
},
]} ]}
size="sm" size="sm"
data-testid="cert-type-select" data-testid="cert-type-select"
@@ -312,8 +363,14 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{certType !== "SERVER" && ( {certType !== "SERVER" && (
<> <>
<FileInput <FileInput
label="Certificate File" label={t(
placeholder="Select certificate file" "workflow.participant.certificateFile",
"Certificate File",
)}
placeholder={t(
"workflow.participant.certificateFilePlaceholder",
"Select certificate file",
)}
value={certFile} value={certFile}
onChange={setCertFile} onChange={setCertFile}
accept=".p12,.pfx,.jks" accept=".p12,.pfx,.jks"
@@ -322,7 +379,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
/> />
<TextInput <TextInput
label="Certificate Password" label={t(
"workflow.participant.certificatePassword",
"Certificate Password",
)}
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.currentTarget.value)} onChange={(e) => setPassword(e.currentTarget.value)}
@@ -386,23 +446,32 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
)} )}
<TextInput <TextInput
label="Location" label={t("workflow.participant.location", "Location")}
placeholder="e.g., San Francisco, CA" placeholder={t(
"workflow.participant.locationPlaceholder",
"e.g., San Francisco, CA",
)}
value={location} value={location}
onChange={(e) => setLocation(e.currentTarget.value)} onChange={(e) => setLocation(e.currentTarget.value)}
size="sm" size="sm"
/> />
<TextInput <TextInput
label="Reason" label={t("workflow.participant.reason", "Reason")}
placeholder="e.g., Document approval" placeholder={t(
"workflow.participant.reasonPlaceholder",
"e.g., Document approval",
)}
value={reason} value={reason}
onChange={(e) => setReason(e.currentTarget.value)} onChange={(e) => setReason(e.currentTarget.value)}
size="sm" size="sm"
/> />
<TextInput <TextInput
label="Page Number (optional)" label={t(
"workflow.participant.pageNumber",
"Page Number (optional)",
)}
type="number" type="number"
value={pageNumber} value={pageNumber}
onChange={(e) => onChange={(e) =>
@@ -423,7 +492,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
color="green" color="green"
data-testid="submit-signature-button" data-testid="submit-signature-button"
> >
Submit Signature {t("workflow.participant.submitSignature", "Submit Signature")}
</Button> </Button>
<Button <Button
@@ -433,7 +502,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
variant="light" variant="light"
data-testid="decline-button" data-testid="decline-button"
> >
Decline {t("workflow.participant.decline", "Decline")}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -442,14 +511,24 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{participant.hasCompleted && ( {participant.hasCompleted && (
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green"> <Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
You have {participant.status === "SIGNED" ? "signed" : "declined"}{" "} {participant.status === "SIGNED"
this document. ? t(
"workflow.participant.completedSigned",
"You have signed this document.",
)
: t(
"workflow.participant.completedDeclined",
"You have declined this document.",
)}
</Alert> </Alert>
)} )}
{participant.isExpired && ( {participant.isExpired && (
<Alert icon={<InfoIcon fontSize="small" />} color="orange"> <Alert icon={<InfoIcon fontSize="small" />} color="orange">
Your access to this document has expired. {t(
"workflow.participant.accessExpired",
"Your access to this document has expired.",
)}
</Alert> </Alert>
)} )}
</Stack> </Stack>
@@ -1,100 +1,219 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { PlanFeature } from "@app/types/license"; import type { PlanFeature } from "@app/types/license";
/** /**
* Shared plan feature definitions for Stirling PDF Self-Hosted * Shared plan feature definitions for Stirling PDF Self-Hosted
* Used by both dynamic (Stripe) and static (fallback) plan displays * Used by both dynamic (Stripe) and static (fallback) plan displays.
*
* These are exposed as hooks so that every feature/highlight string can be
* localized via `t()` while preserving the original shape.
*/ */
export const PLAN_FEATURES = { export interface PlanFeaturesMap {
FREE: PlanFeature[];
SERVER: PlanFeature[];
ENTERPRISE: PlanFeature[];
}
export interface PlanHighlightsMap {
FREE: string[];
SERVER_MONTHLY: string[];
SERVER_YEARLY: string[];
ENTERPRISE_MONTHLY: string[];
ENTERPRISE_YEARLY: string[];
}
export const usePlanFeatures = (): PlanFeaturesMap => {
const { t } = useTranslation();
return useMemo(() => {
const selfHostedDeployment = t(
"plan.features.selfHostedDeployment",
"Self-hosted deployment",
);
const allPdfOperations = t(
"plan.features.allPdfOperations",
"All PDF operations",
);
const secureLoginSupport = t(
"plan.features.secureLoginSupport",
"Secure Login Support",
);
const communitySupport = t(
"plan.features.communitySupport",
"Community support",
);
const regularUpdates = t("plan.features.regularUpdates", "Regular updates");
const upToFiveUsersLowercase = t(
"plan.features.upToFiveUsersLowercase",
"up to 5 users",
);
const upToFiveUsers = t("plan.features.upToFiveUsers", "Up to 5 users");
const unlimitedUsers = t("plan.features.unlimitedUsers", "Unlimited users");
const googleDriveIntegration = t(
"plan.features.googleDriveIntegration",
"Google drive integration",
);
const externalDatabase = t(
"plan.features.externalDatabase",
"External Database",
);
const editingTextInPdfs = t(
"plan.features.editingTextInPdfs",
"Editing text in pdfs",
);
const usersLimitedToSeats = t(
"plan.features.usersLimitedToSeats",
"Users limited to seats",
);
const sso = t("plan.features.sso", "SSO");
const saml = t("plan.features.saml", "SAML");
const auditing = t("plan.features.auditing", "Auditing");
const usageTracking = t("plan.features.usageTracking", "Usage tracking");
const prometheusSupport = t(
"plan.features.prometheusSupport",
"Prometheus Support",
);
const customPdfMetadata = t(
"plan.features.customPdfMetadata",
"Custom PDF metadata",
);
return {
FREE: [ FREE: [
{ name: "Self-hosted deployment", included: true }, { name: selfHostedDeployment, included: true },
{ name: "All PDF operations", included: true }, { name: allPdfOperations, included: true },
{ name: "Secure Login Support", included: true }, { name: secureLoginSupport, included: true },
{ name: "Community support", included: true }, { name: communitySupport, included: true },
{ name: "Regular updates", included: true }, { name: regularUpdates, included: true },
{ name: "up to 5 users", included: true }, { name: upToFiveUsersLowercase, included: true },
{ name: "Unlimited users", included: false }, { name: unlimitedUsers, included: false },
{ name: "Google drive integration", included: false }, { name: googleDriveIntegration, included: false },
{ name: "External Database", included: false }, { name: externalDatabase, included: false },
{ name: "Editing text in pdfs", included: false }, { name: editingTextInPdfs, included: false },
{ name: "Users limited to seats", included: false }, { name: usersLimitedToSeats, included: false },
{ name: "SSO", included: false }, { name: sso, included: false },
{ name: "SAML", included: false }, { name: saml, included: false },
{ name: "Auditing", included: false }, { name: auditing, included: false },
{ name: "Usage tracking", included: false }, { name: usageTracking, included: false },
{ name: "Prometheus Support", included: false }, { name: prometheusSupport, included: false },
{ name: "Custom PDF metadata", included: false }, { name: customPdfMetadata, included: false },
] as PlanFeature[], ],
SERVER: [ SERVER: [
{ name: "Self-hosted deployment", included: true }, { name: selfHostedDeployment, included: true },
{ name: "All PDF operations", included: true }, { name: allPdfOperations, included: true },
{ name: "Secure Login Support", included: true }, { name: secureLoginSupport, included: true },
{ name: "Community support", included: true }, { name: communitySupport, included: true },
{ name: "Regular updates", included: true }, { name: regularUpdates, included: true },
{ name: "Up to 5 users", included: false }, { name: upToFiveUsers, included: false },
{ name: "Unlimited users", included: true }, { name: unlimitedUsers, included: true },
{ name: "Google drive integration", included: true }, { name: googleDriveIntegration, included: true },
{ name: "External Database", included: true }, { name: externalDatabase, included: true },
{ name: "Editing text in pdfs", included: true }, { name: editingTextInPdfs, included: true },
{ name: "Users limited to seats", included: false }, { name: usersLimitedToSeats, included: false },
{ name: "SSO", included: true }, { name: sso, included: true },
{ name: "SAML", included: false }, { name: saml, included: false },
{ name: "Auditing", included: false }, { name: auditing, included: false },
{ name: "Usage tracking", included: false }, { name: usageTracking, included: false },
{ name: "Prometheus Support", included: false }, { name: prometheusSupport, included: false },
{ name: "Custom PDF metadata", included: false }, { name: customPdfMetadata, included: false },
] as PlanFeature[], ],
ENTERPRISE: [ ENTERPRISE: [
{ name: "Self-hosted deployment", included: true }, { name: selfHostedDeployment, included: true },
{ name: "All PDF operations", included: true }, { name: allPdfOperations, included: true },
{ name: "Secure Login Support", included: true }, { name: secureLoginSupport, included: true },
{ name: "Community support", included: true }, { name: communitySupport, included: true },
{ name: "Regular updates", included: true }, { name: regularUpdates, included: true },
{ name: "up to 5 users", included: false }, { name: upToFiveUsersLowercase, included: false },
{ name: "Unlimited users", included: false }, { name: unlimitedUsers, included: false },
{ name: "Google drive integration", included: true }, { name: googleDriveIntegration, included: true },
{ name: "External Database", included: true }, { name: externalDatabase, included: true },
{ name: "Editing text in pdfs", included: true }, { name: editingTextInPdfs, included: true },
{ name: "Users limited to seats", included: true }, { name: usersLimitedToSeats, included: true },
{ name: "SSO", included: true }, { name: sso, included: true },
{ name: "SAML", included: true }, { name: saml, included: true },
{ name: "Auditing", included: true }, { name: auditing, included: true },
{ name: "Usage tracking", included: true }, { name: usageTracking, included: true },
{ name: "Prometheus Support", included: true }, { name: prometheusSupport, included: true },
{ name: "Custom PDF metadata", included: true }, { name: customPdfMetadata, included: true },
] as PlanFeature[], ],
} as const; };
}, [t]);
};
export const PLAN_HIGHLIGHTS = { export const usePlanHighlights = (): PlanHighlightsMap => {
FREE: ["Up to 5 users", "Self-hosted", "All basic features"], const { t } = useTranslation();
SERVER_MONTHLY: [
return useMemo(() => {
const selfHostedOnInfrastructure = t(
"plan.highlights.selfHostedOnInfrastructure",
"Self-hosted on your infrastructure", "Self-hosted on your infrastructure",
);
const unlimitedUsers = t(
"plan.highlights.unlimitedUsers",
"Unlimited users", "Unlimited users",
);
const advancedIntegrations = t(
"plan.highlights.advancedIntegrations",
"Advanced integrations", "Advanced integrations",
"SSO (OAuth2/OIDC)", );
const ssoOAuth = t("plan.highlights.ssoOAuth", "SSO (OAuth2/OIDC)");
const editingTextInPdfsCaps = t(
"plan.highlights.editingTextInPdfsCaps",
"Editing text in PDFs", "Editing text in PDFs",
"Cancel anytime", );
const enterpriseFeatures = t(
"plan.highlights.enterpriseFeatures",
"Enterprise features (SAML, Auditing)",
);
const usageTrackingPrometheus = t(
"plan.highlights.usageTrackingPrometheus",
"Usage tracking & Prometheus",
);
const customPdfMetadata = t(
"plan.highlights.customPdfMetadata",
"Custom PDF metadata",
);
const saveWithAnnualBilling = t(
"plan.highlights.saveWithAnnualBilling",
"Save with annual billing",
);
return {
FREE: [
t("plan.highlights.upToFiveUsers", "Up to 5 users"),
t("plan.highlights.selfHosted", "Self-hosted"),
t("plan.highlights.allBasicFeatures", "All basic features"),
],
SERVER_MONTHLY: [
selfHostedOnInfrastructure,
unlimitedUsers,
advancedIntegrations,
ssoOAuth,
editingTextInPdfsCaps,
t("plan.highlights.cancelAnytime", "Cancel anytime"),
], ],
SERVER_YEARLY: [ SERVER_YEARLY: [
"Self-hosted on your infrastructure", selfHostedOnInfrastructure,
"Unlimited users", unlimitedUsers,
"Advanced integrations", advancedIntegrations,
"SSO (OAuth2/OIDC)", ssoOAuth,
"Editing text in PDFs", editingTextInPdfsCaps,
"Save with annual billing", saveWithAnnualBilling,
], ],
ENTERPRISE_MONTHLY: [ ENTERPRISE_MONTHLY: [
"Enterprise features (SAML, Auditing)", enterpriseFeatures,
"Usage tracking & Prometheus", usageTrackingPrometheus,
"Custom PDF metadata", customPdfMetadata,
"Per-seat licensing", t("plan.highlights.perSeatLicensing", "Per-seat licensing"),
], ],
ENTERPRISE_YEARLY: [ ENTERPRISE_YEARLY: [
"Enterprise features (SAML, Auditing)", enterpriseFeatures,
"Usage tracking & Prometheus", usageTrackingPrometheus,
"Custom PDF metadata", customPdfMetadata,
"Save with annual billing", saveWithAnnualBilling,
], ],
} as const; };
}, [t]);
};
@@ -24,6 +24,10 @@ import {
import { useLicense } from "@app/contexts/LicenseContext"; import { useLicense } from "@app/contexts/LicenseContext";
import { isSupabaseConfigured } from "@app/services/supabaseClient"; import { isSupabaseConfigured } from "@app/services/supabaseClient";
import { getPreferredCurrency } from "@app/utils/currencyDetection"; import { getPreferredCurrency } from "@app/utils/currencyDetection";
import {
usePlanFeatures,
usePlanHighlights,
} from "@app/constants/planConstants";
export interface CheckoutOptions { export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise minimumSeats?: number; // Override calculated seats for enterprise
@@ -57,6 +61,8 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
}) => { }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { refetchLicense } = useLicense(); const { refetchLicense } = useLicense();
const planFeatures = usePlanFeatures();
const planHighlights = usePlanHighlights();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] = const [selectedPlanGroup, setSelectedPlanGroup] =
@@ -85,7 +91,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
try { try {
setPlansLoading(true); setPlansLoading(true);
const response = await licenseService.getPlans(currency); const response = await licenseService.getPlans(
planFeatures,
planHighlights,
currency,
);
setPlans(response.plans); setPlans(response.plans);
setPlansLoaded(true); setPlansLoaded(true);
} catch (error) { } catch (error) {
@@ -95,7 +105,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
setPlansLoading(false); setPlansLoading(false);
} }
}, },
[plansLoading], [plansLoading, planFeatures, planHighlights],
); );
const refetchPlans = useCallback(() => { const refetchPlans = useCallback(() => {
@@ -1,8 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import licenseService, { import licenseService, {
PlanTier, PlanTier,
PlansResponse, PlansResponse,
} from "@app/services/licenseService"; } from "@app/services/licenseService";
import {
usePlanFeatures,
usePlanHighlights,
} from "@app/constants/planConstants";
export interface UsePlansReturn { export interface UsePlansReturn {
plans: PlanTier[]; plans: PlanTier[];
@@ -12,16 +16,22 @@ export interface UsePlansReturn {
} }
export const usePlans = (currency: string = "gbp"): UsePlansReturn => { export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
const planFeatures = usePlanFeatures();
const planHighlights = usePlanHighlights();
const [plans, setPlans] = useState<PlanTier[]>([]); const [plans, setPlans] = useState<PlanTier[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fetchPlans = async () => { const fetchPlans = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const data: PlansResponse = await licenseService.getPlans(currency); const data: PlansResponse = await licenseService.getPlans(
planFeatures,
planHighlights,
currency,
);
setPlans(data.plans); setPlans(data.plans);
} catch (err) { } catch (err) {
console.error("Error fetching plans:", err); console.error("Error fetching plans:", err);
@@ -29,11 +39,11 @@ export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [currency, planFeatures, planHighlights]);
useEffect(() => { useEffect(() => {
fetchPlans(); fetchPlans();
}, [currency]); }, [fetchPlans]);
return { return {
plans, plans,
@@ -1,7 +1,10 @@
import apiClient from "@app/services/apiClient"; import apiClient from "@app/services/apiClient";
import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient"; import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient";
import { getCheckoutMode } from "@app/utils/protocolDetection"; import { getCheckoutMode } from "@app/utils/protocolDetection";
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants"; import type {
PlanFeaturesMap,
PlanHighlightsMap,
} from "@app/constants/planConstants";
import type { LicenseInfo, PlanFeature } from "@app/types/license"; import type { LicenseInfo, PlanFeature } from "@app/types/license";
export interface PlanTier { export interface PlanTier {
@@ -102,9 +105,18 @@ const SELF_HOSTED_LOOKUP_KEYS = [
const licenseService = { const licenseService = {
/** /**
* Get available plans with pricing for the specified currency * Get available plans with pricing for the specified currency.
*
* Feature and highlight labels are passed in by the caller (a React hook /
* context that resolves them via `usePlanFeatures` / `usePlanHighlights`),
* which keeps this service free of React/i18n dependencies while still
* returning fully localized plan content.
*/ */
async getPlans(currency: string = "usd"): Promise<PlansResponse> { async getPlans(
planFeatures: PlanFeaturesMap,
planHighlights: PlanHighlightsMap,
currency: string = "usd",
): Promise<PlansResponse> {
try { try {
// Check if Supabase is configured // Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) { if (!isSupabaseConfigured || !supabase) {
@@ -179,8 +191,8 @@ const licenseService = {
currency: currencySymbol, currency: currencySymbol,
period: "/month", period: "/month",
popular: false, popular: false,
features: PLAN_FEATURES.SERVER, features: planFeatures.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY, highlights: planHighlights.SERVER_MONTHLY,
}, },
{ {
id: "selfhosted:server:yearly", id: "selfhosted:server:yearly",
@@ -190,8 +202,8 @@ const licenseService = {
currency: currencySymbol, currency: currencySymbol,
period: "/year", period: "/year",
popular: true, popular: true,
features: PLAN_FEATURES.SERVER, features: planFeatures.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_YEARLY, highlights: planHighlights.SERVER_YEARLY,
}, },
{ {
id: "selfhosted:enterprise:monthly", id: "selfhosted:enterprise:monthly",
@@ -203,8 +215,8 @@ const licenseService = {
period: "/month", period: "/month",
popular: false, popular: false,
requiresSeats: true, requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE, features: planFeatures.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY, highlights: planHighlights.ENTERPRISE_MONTHLY,
}, },
{ {
id: "selfhosted:enterprise:yearly", id: "selfhosted:enterprise:yearly",
@@ -216,8 +228,8 @@ const licenseService = {
period: "/year", period: "/year",
popular: false, popular: false,
requiresSeats: true, requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE, features: planFeatures.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_YEARLY, highlights: planHighlights.ENTERPRISE_YEARLY,
}, },
]; ];
@@ -240,8 +252,8 @@ const licenseService = {
currency: currencySymbol, currency: currencySymbol,
period: "", period: "",
popular: false, popular: false,
features: PLAN_FEATURES.FREE, features: planFeatures.FREE,
highlights: PLAN_HIGHLIGHTS.FREE, highlights: planHighlights.FREE,
}; };
const allPlans = [freePlan, ...validPlans]; const allPlans = [freePlan, ...validPlans];