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;
import java.util.List;
/** Provides metadata about tool endpoints for internal dispatch. */
public interface ToolMetadataService {
/** Returns true if the given operation path accepts multiple input files. */
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
* 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.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.SvgSanitizer;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -36,6 +37,7 @@ public class OverlayImageController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final SvgSanitizer svgSanitizer;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
@@ -61,6 +63,9 @@ public class OverlayImageController {
byte[] imageBytes = imageFile.getBytes();
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
if (isSvg) {
imageBytes = svgSanitizer.sanitize(imageBytes);
}
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
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";
}
@Override
public List<String> getExtensionTypes(boolean output, String operationName) {
if (outputToFileTypes.isEmpty()) {
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.UserAgentAdapter;
import org.apache.batik.gvt.GraphicsNode;
import org.apache.batik.util.ParsedURL;
import org.apache.batik.util.XMLResourceDescriptor;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -39,7 +40,16 @@ public class SvgOverlayUtil {
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);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -93,6 +93,11 @@ posthog.host=https://eu.i.posthog.com
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
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.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.SvgSanitizer;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -52,6 +53,7 @@ class OverlayImageControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private SvgSanitizer svgSanitizer;
@InjectMocks private OverlayImageController controller;
@@ -205,6 +207,52 @@ class OverlayImageControllerTest {
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
void overlayImage_withCoordinates_usesXY() throws Exception {
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.repository",
"stirling.software.proprietary.storage.repository",
"stirling.software.proprietary.workflow.repository"
"stirling.software.proprietary.workflow.repository",
"stirling.software.proprietary.policy.store"
})
@EntityScan({
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.storage.model",
"stirling.software.proprietary.workflow.model"
"stirling.software.proprietary.workflow.model",
"stirling.software.proprietary.policy.store"
})
public class DatabaseConfig {
@@ -14,14 +14,9 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
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.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
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.service.CustomPDFDocumentFactory;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile;
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.AiDocumentIngestRequest;
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.AiWorkflowResponse;
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.service.PdfContentExtractor.LoadedFile;
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
@@ -72,12 +71,11 @@ public class AiWorkflowService {
private final AiEngineClient aiEngineClient;
private final PdfContentExtractor pdfContentExtractor;
private final ObjectMapper objectMapper;
private final InternalApiClient internalApiClient;
private final FileStorage fileStorage;
private final ToolMetadataService toolMetadataService;
private final TempFileManager tempFileManager;
private final FileIdStrategy fileIdStrategy;
private final AiEngineEndpointResolver endpointResolver;
private final PolicyExecutor policyExecutor;
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
@@ -86,24 +84,22 @@ public class AiWorkflowService {
AiEngineClient aiEngineClient,
PdfContentExtractor pdfContentExtractor,
ObjectMapper objectMapper,
InternalApiClient internalApiClient,
FileStorage fileStorage,
ToolMetadataService toolMetadataService,
TempFileManager tempFileManager,
FileIdStrategy fileIdStrategy,
AiEngineEndpointResolver endpointResolver,
PolicyExecutor policyExecutor,
@Autowired(required = false) UserServiceInterface userService,
ApplicationProperties applicationProperties) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.aiEngineClient = aiEngineClient;
this.pdfContentExtractor = pdfContentExtractor;
this.objectMapper = objectMapper;
this.internalApiClient = internalApiClient;
this.fileStorage = fileStorage;
this.toolMetadataService = toolMetadataService;
this.tempFileManager = tempFileManager;
this.fileIdStrategy = fileIdStrategy;
this.endpointResolver = endpointResolver;
this.policyExecutor = policyExecutor;
this.userService = userService;
this.applicationProperties = applicationProperties;
}
@@ -150,16 +146,6 @@ public class AiWorkflowService {
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 {
return orchestrate(request, NOOP_LISTENER);
}
@@ -377,7 +363,6 @@ public class AiWorkflowService {
pages.size());
}
@SuppressWarnings("unchecked")
private WorkflowState onToolCall(
AiWorkflowResponse response,
Map<String, MultipartFile> filesById,
@@ -394,8 +379,14 @@ public class AiWorkflowService {
try {
List<Resource> inputFiles = toResources(filesById);
listener.onProgress(AiWorkflowProgressEvent.executingTool(endpointPath, 1, 1));
ToolResult result = executeStep(endpointPath, parameters, inputFiles);
PipelineDefinition definition =
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(
buildCompletedResponse(
response.getRationale(),
@@ -469,38 +460,32 @@ public class AiWorkflowService {
cannotContinue("AI engine returned a plan with no steps."));
}
try {
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++) {
Map<String, Object> step = steps.get(i);
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()) {
return new WorkflowState.Terminal(
cannotContinue("Plan step " + (i + 1) + " has no tool endpoint."));
}
listener.onProgress(
AiWorkflowProgressEvent.executingTool(endpointPath, i + 1, steps.size()));
ToolResult stepResult = executeStep(endpointPath, parameters, currentFiles);
currentFiles = stepResult.files();
if (stepResult.report() != null) {
lastReport = stepResult.report();
lastReportTool = endpointPath;
}
List<PipelineStep> pipelineSteps = new ArrayList<>();
for (int i = 0; i < steps.size(); i++) {
Map<String, Object> step = steps.get(i);
String endpointPath = (String) step.get("tool");
if (endpointPath == null || endpointPath.isBlank()) {
return new WorkflowState.Terminal(
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));
}
try {
List<Resource> inputFiles = toResources(filesById);
PipelineDefinition definition =
new PipelineDefinition(summary, pipelineSteps, OutputSpec.inline());
PolicyExecutionResult result =
policyExecutor.execute(
definition, PolicyInputs.of(inputFiles), stepProgress(listener));
// 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.
if (resumeWith != null && !resumeWith.isBlank() && lastReport != null) {
if (resumeWith != null && !resumeWith.isBlank() && result.report() != null) {
WorkflowTurnRequest resumeRequest = new WorkflowTurnRequest();
resumeRequest.setUserMessage(previousRequest.getUserMessage());
resumeRequest.setFiles(previousRequest.getFiles());
@@ -510,14 +495,14 @@ public class AiWorkflowService {
.getArtifacts()
.add(
new PdfContentExtractor.ToolReportArtifact(
lastReportTool, lastReport));
result.reportTool(), result.report()));
resumeRequest.setResumeWith(resumeWith);
return new WorkflowState.Pending(resumeRequest);
}
return new WorkflowState.Terminal(
buildCompletedResponse(
summary, currentFiles, inputFileNames(filesById), lastReport));
summary, result.files(), inputFileNames(filesById), result.report()));
} catch (InternalApiTimeoutException e) {
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
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
* 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.
* Adapt the AI workflow's {@link ProgressListener} to the engine's {@link
* PolicyProgressListener}: each step start maps to an {@code EXECUTING_TOOL} progress event
* carrying the tool path and 1-based step position, preserving the event shape the frontend
* already renders.
*/
private ToolResult executeStep(
String endpointPath, Map<String, Object> parameters, List<Resource> inputFiles)
throws IOException {
List<Resource> files = new ArrayList<>();
JsonNode report = null;
if (toolMetadataService.isMultiInput(endpointPath)) {
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();
}
private static PolicyProgressListener stepProgress(ProgressListener listener) {
return new PolicyProgressListener() {
@Override
public void onStepStart(int stepIndex, int stepCount, String operation) {
listener.onProgress(
AiWorkflowProgressEvent.executingTool(operation, stepIndex, stepCount));
}
}
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 {
@@ -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.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.policy.engine.PolicyExecutor;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@@ -107,18 +108,20 @@ class AiWorkflowServiceTest {
.when(fileIdStrategy.idFor(any(MultipartFile.class)))
.thenAnswer(inv -> ((MultipartFile) inv.getArgument(0)).getOriginalFilename());
PolicyExecutor policyExecutor =
new PolicyExecutor(
internalApiClient, toolMetadataService, tempFileManager, objectMapper);
service =
new AiWorkflowService(
pdfDocumentFactory,
aiEngineClient,
pdfContentExtractor,
objectMapper,
internalApiClient,
fileStorage,
toolMetadataService,
tempFileManager,
fileIdStrategy,
endpointResolver,
policyExecutor,
null,
new ApplicationProperties());
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
@@ -12,7 +12,6 @@ cancel = "Cancel"
changedCredsMessage = "Credentials changed!"
chooseFile = "Choose File"
close = "Close"
color = "Colour"
comingSoon = "Coming soon"
confirm = "Confirm"
confirmClose = "Confirm Close"
@@ -199,6 +198,10 @@ ssoDescription = "Two-factor authentication is managed by your identity provider
ssoManaged = "Configure MFA through your identity provider."
title = "Two-factor authentication"
[account.overview]
logOut = "Log out"
signedInAs = "Signed in as: {{email}}"
[add-page-numbers]
tags = "paginate,label,organize,index"
@@ -226,6 +229,9 @@ title = "What it does"
[AddAttachmentsRequest.tooltip.header]
title = "About Add Attachments"
[addFileCard]
upload = "Upload"
[addImage]
applySignatures = "Apply Images"
header = "Add images to PDFs"
@@ -606,6 +612,7 @@ label = "Temp File Management"
[admin.settings.advanced.tempFileManagement.baseTmpDir]
description = "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)"
label = "Base Temp Directory"
placeholder = "Default: java.io.tmpdir/stirling-pdf"
[admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes]
description = "How often to run cleanup (in minutes)"
@@ -618,6 +625,7 @@ label = "Cleanup System Temp"
[admin.settings.advanced.tempFileManagement.libreofficeDir]
description = "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)"
label = "LibreOffice Temp Directory"
placeholder = "Default: baseTmpDir/libreoffice"
[admin.settings.advanced.tempFileManagement.maxAgeHours]
description = "Maximum age in hours before temp files are cleaned up"
@@ -634,6 +642,7 @@ label = "Startup Cleanup"
[admin.settings.advanced.tempFileManagement.systemTempDir]
description = "System temp directory to clean (only used if cleanupSystemTemp is enabled)"
label = "System Temp Directory"
placeholder = "System temp directory path"
[admin.settings.advanced.tessdataDir]
description = "Path to the tessdata directory for OCR language files"
@@ -839,6 +848,7 @@ label = "Database Name"
[admin.settings.database.password]
description = "Database authentication password"
label = "Password"
placeholder = "Enter database password"
[admin.settings.database.port]
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]
description = "Select endpoint groups to disable"
label = "Disabled Endpoint Groups"
placeholder = "Select groups to disable"
[admin.settings.endpoints.toRemove]
description = "Select individual endpoints to disable"
label = "Disabled Endpoints"
placeholder = "Select endpoints to disable"
[admin.settings.enterpriseRequired]
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]
description = "Password for SMTP authentication"
label = "SMTP Password"
placeholder = "Enter SMTP password"
[admin.settings.mail.port]
description = "The port number for SMTP connection (typically 25, 465, or 587)"
@@ -1097,6 +1110,10 @@ label = "SMTP Port"
description = "Username for SMTP authentication"
label = "SMTP Username"
[admin.settings.plan.noData]
message = "Plans data is not available at the moment."
title = "No data available"
[admin.settings.premium]
description = "Configure your premium or enterprise license key."
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?"
[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:"
title = "Premium Features Distributed"
@@ -1526,10 +1546,10 @@ section_title = "Agents"
show_less = "Show less"
start_chat = "Start chatting"
stirling_description = "Your general-purpose PDF assistant"
stirling_running = "Running..."
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_name = "Stirling"
stirling_running = "Running..."
stirling_tooltip = "Stirling agent"
view_all = "View all agents"
@@ -2126,11 +2146,31 @@ placeholder = "Number of pages"
title = "Last N Pages"
[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)"
comma = "Comma: , or | - combine selections (e.g., 1-10, 20)"
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]
fromPlaceholder = "From"
@@ -2782,6 +2822,9 @@ unknownTool = "Unknown tool"
[cloudBadge]
tooltip = "This operation will use your cloud credits"
[color.eyeDropper]
tooltip = "Pick colour from screen"
[colorPicker]
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"
_value = "Compression Settings"
[compress.settings]
desiredSize = "Desired File Size"
desiredSizePlaceholder = "Enter size"
[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."
title = "Description"
@@ -3496,6 +3543,13 @@ docsLink = "View installation documentation"
message = "Stirling-PDF does not have permission to update itself on this machine."
title = "Administrator permissions required"
[dropdownList]
searchPlaceholder = "Search..."
[editableSecretField]
edit = "Edit"
editSecretValue = "Edit secret value"
[editTableOfContents]
submit = "Apply table of contents"
@@ -3583,6 +3637,9 @@ tabTitle = "Outline workspace"
description = "Select the Edit Table of Contents tool to load its workspace."
title = "Open the tool to start editing"
[emptyFilesState]
upload = "Upload"
[encryptedPdfUnlock]
description = "This PDF is password protected. Enter the password so you can continue working with it."
emptyResponse = "Password removal did not produce a file."
@@ -3697,6 +3754,10 @@ title = "Settings"
[extractPages.tooltip]
description = "Extracts the selected pages into a new PDF, preserving order."
[fileCard]
openInFileEditor = "Open in File Editor"
viewInViewer = "View in Viewer"
[fileChooser]
click = "Click"
dragAndDrop = "Drag & Drop"
@@ -3706,6 +3767,9 @@ extractPDF = "Extracting..."
hoveredDragAndDrop = "Drag & Drop file(s) here"
or = "or"
[fileDropdownMenu]
closeFile = "Close file"
[fileEditor]
addFiles = "Add Files"
pageCount = "{{count}} pages"
@@ -3872,6 +3936,10 @@ openSettings = "Open settings"
search = "Search"
searchPlaceholder = "Search files..."
[fileSidebar.fileItem]
closeViewer = "Close viewer"
openInViewer = "Open in viewer"
[filesPage]
addToWorkspace = "Add to workspace"
addToWorkspaceCount = "Add {{count}} to workspace"
@@ -4178,6 +4246,15 @@ title = "About Flattening PDFs"
discord = "Discord"
issues = "GitHub"
[formFill]
flattenAfterFilling = "Flatten after filling"
rescanFields = "Re-scan fields"
rescanFormFields = "Re-scan form fields"
save = "Save"
[formFill.sidebar]
close = "Close sidebar"
[getPdfInfo]
downloadJson = "Download JSON"
downloads = "Downloads"
@@ -4737,6 +4814,11 @@ tags = "markup,web-content,transformation,convert"
title = "HTML To PDF"
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]
tags = "conversion,img,jpg,picture,photo"
@@ -4755,6 +4837,9 @@ title = "Image to PDF"
4 = "Merge into single PDF"
5 = "Convert to separate PDFs"
[infoBanner]
dismiss = "Dismiss"
[invite]
acceptError = "Failed to create account"
accountFor = "Creating account for"
@@ -5090,6 +5175,7 @@ failed = "OCR operation failed"
[ocr.languagePicker]
additionalLanguages = "Looking for additional languages?"
loading = "Loading available languages..."
viewSetupGuide = "View setup guide →"
[ocr.operation]
@@ -5226,6 +5312,10 @@ daysRemainingSingular = "{{days}} day remaining"
title = "Your 30-Day Pro Trial"
trialEnds = "Trial ends {{date}}"
[onboarding.mfa]
authenticationCode = "Authentication code"
qrCodeLoading = "Generating your QR code…"
[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."
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]
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]
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"
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]
header = "Extract Pages"
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
@@ -5371,12 +5475,14 @@ addBorder = "Add Borders"
borderWidth = "Border Thickness"
bottom = "Bottom Margin"
cols = "Columns"
colsPlaceholder = "Enter columns"
header = "Multi Page Layout"
innerMargin = "Inner Margin"
left = "Left Margin"
pagesPerSheet = "Pages per sheet:"
right = "Right Margin"
rows = "Rows"
rowsPlaceholder = "Enter rows"
submit = "Submit"
tags = "merge,composite,single-view,organize"
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."
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]
text = "Enable borders to draw lines around each placed page. This can help visual separation or trimming."
title = "Add Borders"
@@ -6071,6 +6185,26 @@ pdfTools = "Basic PDF Tools"
priority = "Priority Support"
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]
forever = "Forever free"
highlight1 = "Limited Tool Usage Per week"
@@ -6079,6 +6213,22 @@ highlight3 = "Community support"
included = "Included"
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]
body = "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly."
cta = "See plans"
@@ -6389,6 +6539,7 @@ allSessions = "All Sessions"
allTools = "Tools"
automate = "Automate"
back = "Back"
backToAllTools = "Back to all tools"
certSign = "Certificate Sign"
completedSessions = "Completed Sessions"
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."
title = "Signature Order"
[settings]
close = "Close"
[settings.configuration]
advanced = "Advanced"
database = "Database"
@@ -7542,6 +7696,7 @@ sharedDescription = "All users can see and use these signatures."
sharedHeading = "Shared Signatures"
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
tempStorageTitle = "Temporary browser storage"
use = "Use signature"
[sign.saved.status]
saved = "Saved"
@@ -8122,9 +8277,18 @@ center = "Center"
left = "Left"
right = "Right"
[textInput]
clear = "Clear input"
[theme]
toggle = "Toggle Theme"
[time.relative]
daysAgo = "{{count}}d ago"
hoursAgo = "{{count}}h ago"
justNow = "just now"
minutesAgo = "{{count}}m ago"
[timestampPdf]
completed = "PDF timestamped successfully"
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]
settings = "Settings"
[time.relative]
daysAgo = "{{count}}d ago"
hoursAgo = "{{count}}h ago"
justNow = "just now"
minutesAgo = "{{count}}m ago"
[toast]
dismiss = "Dismiss"
toggleDetails = "Toggle details"
[tool]
endpointUnavailable = "This tool is unavailable on your server."
@@ -8243,6 +8405,9 @@ verification = "Verification"
noSearchResults = "No tools found"
noTools = "No tools available"
[tooltip]
close = "Close tooltip"
[unlockPDFForms]
description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable."
filenamePrefix = "unlocked_forms"
@@ -8269,6 +8434,7 @@ breakingChangesDefault = "This version contains breaking changes."
breakingChangesDetected = "Breaking Changes Detected"
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
close = "Close"
closeModal = "Close update modal"
current = "Current Version"
defaultRecommendation = "This update contains important fixes and improvements."
downloadLatest = "Download Latest"
@@ -8520,6 +8686,9 @@ typeInsertText = "Insert Text"
typeReplaceText = "Replace Text"
viewComment = "View comment"
[viewer.error]
noFileProvided = "Error: No file provided to viewer"
[viewer.formBar]
apply = "Apply Changes"
dismiss = "Dismiss"
@@ -8529,6 +8698,9 @@ title = "Form Fields"
unsavedBadge = "Unsaved"
unsavedDesc = "You have unsaved changes"
[viewer.link]
delete = "Delete link"
[viewer.nonPdf]
columnDefault = "Column {{index}}"
convertToPdf = "Convert to PDF"
@@ -8544,6 +8716,19 @@ renderMarkdown = "Render markdown"
sortedBy = "Sorted by: {{column}}"
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]
header = "View PDF"
tags = "view,read,annotate,text,image,highlight,edit"
@@ -8792,6 +8977,37 @@ toggleSidebar = "Toggle Sidebar"
toggleTheme = "Toggle Theme"
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]
title = "Workspace"
@@ -17,6 +17,7 @@
"height": 800,
"resizable": true,
"fullscreen": false,
"dragDropEnabled": false,
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
}
]
@@ -8,6 +8,7 @@ import {
Group,
} from "@mantine/core";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import ColorizeIcon from "@mui/icons-material/Colorize";
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
@@ -33,6 +34,7 @@ export function ColorControl({
label,
disabled = false,
}: ColorControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
// Buffer the colour locally so the picker stays responsive during drag.
// Only propagate to the parent (which triggers expensive annotation updates)
@@ -111,7 +113,9 @@ export function ColorControl({
/>
{supportsEyeDropper && (
<Group justify="flex-end">
<Tooltip label="Pick colour from screen">
<Tooltip
label={t("color.eyeDropper.tooltip", "Pick colour from screen")}
>
<ActionIcon
variant="subtle"
color="gray"
@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
@@ -12,6 +13,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange,
disabled = false,
}) => {
const { t } = useTranslation();
const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => {
@@ -57,9 +59,12 @@ export const ImageTool: React.FC<ImageToolProps> = ({
<ImageUploader
onImageChange={handleImageUpload}
disabled={disabled}
label="Upload Image"
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."
label={t("image.upload.label", "Upload Image")}
placeholder={t("image.upload.placeholder", "Select image file")}
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>
</BaseAnnotationTool>
@@ -134,7 +134,7 @@ const AddFileCard = ({
)}
</Button>
<Button
aria-label="Upload"
aria-label={t("addFileCard.upload", "Upload")}
style={{
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
@@ -77,7 +77,7 @@ const EmptyFilesState: React.FC = () => {
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
aria-label="Upload"
aria-label={t("emptyFilesState.upload", "Upload")}
style={{
backgroundColor: "var(--bg-file-manager)",
color: "var(--landing-button-color)",
@@ -16,6 +16,7 @@ import {
TextInput,
} from "@mantine/core";
import { QRCodeSVG } from "qrcode.react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
@@ -31,6 +32,7 @@ interface MFASetupSlideProps {
}
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const { t } = useTranslation();
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
null,
);
@@ -179,7 +181,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
{mfaLoading && !isReady && (
<Group gap="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>
)}
@@ -189,7 +193,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
<Stack gap="sm">
<TextInput
id="mfa-setup-code"
label="Authentication code"
label={t(
"onboarding.mfa.authenticationCode",
"Authentication code",
)}
placeholder="123456"
value={mfaSetupCode}
onChange={(event) =>
@@ -37,11 +37,20 @@ export default function SecurityCheckSlide({
</div>
<Select
placeholder="Confirm your role"
placeholder={i18n.t(
"onboarding.securityCheck.rolePlaceholder",
"Confirm your role",
)}
value={selectedRole}
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) =>
onRoleSelect((value as "admin" | "user") ?? null)
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import {
useNavigationGuard,
@@ -33,6 +34,7 @@ export interface PageEditorProps {
}
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const { t } = useTranslation();
// Use split contexts to prevent re-renders
const { state, selectors } = useFileState();
const { actions } = useFileActions();
@@ -688,9 +690,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
<Text size="lg" c="dimmed">
📄
</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">
Add files to start editing pages
{t(
"pageEditor.emptyState.body",
"Add files to start editing pages",
)}
</Text>
</Stack>
</Center>
@@ -6,6 +6,7 @@ import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
import { useTranslation } from "react-i18next";
interface PageEditorControlsProps {
// Close/Reset functions
@@ -51,6 +52,7 @@ const PageEditorControls = ({
displayDocument,
splitPositions,
}: PageEditorControlsProps) => {
const { t } = useTranslation();
// Calculate split tooltip text using smart toggle logic
const getSplitTooltip = () => {
if (!splitPositions || !displayDocument || selectedPageIds.length === 0) {
@@ -131,7 +133,7 @@ const PageEditorControls = ({
}}
>
{/* Undo/Redo */}
<Tooltip label="Undo">
<Tooltip label={t("pageEditor.toolbar.undo", "Undo")}>
<ActionIcon
onClick={onUndo}
disabled={!canUndo}
@@ -145,7 +147,7 @@ const PageEditorControls = ({
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<Tooltip label={t("pageEditor.toolbar.redo", "Redo")}>
<ActionIcon
onClick={onRedo}
disabled={!canRedo}
@@ -170,7 +172,9 @@ const PageEditorControls = ({
/>
{/* Page Operations */}
<Tooltip label="Rotate Selected Left">
<Tooltip
label={t("pageEditor.toolbar.rotateLeft", "Rotate Selected Left")}
>
<ActionIcon
onClick={() => onRotate("left")}
disabled={selectedPageIds.length === 0}
@@ -187,7 +191,9 @@ const PageEditorControls = ({
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Selected Right">
<Tooltip
label={t("pageEditor.toolbar.rotateRight", "Rotate Selected Right")}
>
<ActionIcon
onClick={() => onRotate("right")}
disabled={selectedPageIds.length === 0}
@@ -204,7 +210,7 @@ const PageEditorControls = ({
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Selected">
<Tooltip label={t("pageEditor.toolbar.delete", "Delete Selected")}>
<ActionIcon
onClick={onDelete}
disabled={selectedPageIds.length === 0}
@@ -26,7 +26,10 @@ const OperatorsSection = ({
className={classes.operatorChip}
onClick={() => onInsertOperator("and")}
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}>
and
@@ -38,7 +41,10 @@ const OperatorsSection = ({
className={classes.operatorChip}
onClick={() => onInsertOperator("or")}
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}>
or
@@ -50,7 +56,10 @@ const OperatorsSection = ({
className={classes.operatorChip}
onClick={() => onInsertOperator("not")}
disabled={!csvInput.trim()}
title="Exclude from selection"
title={t(
"bulkSelection.operators.not.title",
"Exclude from selection",
)}
>
<Text size="xs" fw={500}>
not
@@ -64,7 +73,10 @@ const OperatorsSection = ({
variant="outline"
className={classes.operatorChip}
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}>
even
@@ -75,7 +87,10 @@ const OperatorsSection = ({
variant="outline"
className={classes.operatorChip}
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}>
odd
@@ -45,7 +45,9 @@ const PageSelectionInput = ({
height="1rem"
style={{ color: "var(--text-instruction)" }}
/>
<Text>Page Selection</Text>
<Text>
{t("bulkSelection.pageSelection.title", "Page Selection")}
</Text>
</Flex>
</Tooltip>
{typeof advancedOpened === "boolean" && (
@@ -7,6 +7,7 @@ import React, {
} from "react";
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
@@ -34,6 +35,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
opened,
onClose,
}) => {
const { t } = useTranslation();
const [active, setActive] = useState<NavKey>("general");
const isMobile = useIsMobile();
const navigate = useNavigate();
@@ -333,7 +335,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
ref={closeButtonRef}
variant="subtle"
onClick={handleClose}
aria-label="Close"
aria-label={t("settings.close", "Close")}
data-autofocus
>
<LocalIcon icon="close-rounded" width={18} height={18} />
@@ -8,6 +8,7 @@ import {
Group,
TextInput,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
import SearchIcon from "@mui/icons-material/Search";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
@@ -71,6 +72,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
withinPortal = true,
zIndex = Z_INDEX_AUTOMATE_DROPDOWN,
}) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
const isMultiValue = Array.isArray(value);
@@ -184,7 +186,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
}}
>
<TextInput
placeholder="Search..."
placeholder={t("dropdownList.searchPlaceholder", "Search...")}
value={searchTerm}
onChange={handleSearchChange}
leftSection={<SearchIcon style={{ fontSize: "1rem" }} />}
@@ -98,8 +98,11 @@ export default function EditableSecretField({
variant="light"
onClick={handleEdit}
disabled={disabled}
title="Edit"
aria-label="Edit secret value"
title={t("editableSecretField.edit", "Edit")}
aria-label={t(
"editableSecretField.editSecretValue",
"Edit secret value",
)}
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
@@ -111,7 +111,7 @@ const FileCard = ({
onClick={(e) => e.stopPropagation()}
>
{onView && (
<Tooltip label="View in Viewer">
<Tooltip label={t("fileCard.viewInViewer", "View in Viewer")}>
<ActionIcon
size="sm"
variant="subtle"
@@ -126,7 +126,9 @@ const FileCard = ({
</Tooltip>
)}
{onEdit && (
<Tooltip label="Open in File Editor">
<Tooltip
label={t("fileCard.openInFileEditor", "Open in File Editor")}
>
<ActionIcon
size="sm"
variant="subtle"
@@ -1,5 +1,6 @@
import React from "react";
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import CloseIcon from "@mui/icons-material/Close";
@@ -28,6 +29,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
switchingTo,
viewOptionStyle,
}) => {
const { t } = useTranslation();
return (
<Menu trigger="click" position="bottom" width="30rem">
<Menu.Target>
@@ -95,7 +97,10 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
</Text>
)}
{onFileRemove && (
<Tooltip label="Close file" withArrow>
<Tooltip
label={t("fileDropdownMenu.closeFile", "Close file")}
withArrow
>
<ActionIcon
component="div"
size="xs"
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import type { FileId } from "@app/types/file";
@@ -141,6 +142,7 @@ export function FileItem({
onClick,
onEyeClick,
}: FileItemProps) {
const { t } = useTranslation();
const ext = getFileExtension(name);
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = ext ? ext.toUpperCase() : "File";
@@ -215,7 +217,11 @@ export function FileItem({
}}
tabIndex={-1}
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
className="file-sidebar-eye-open"
@@ -1,5 +1,6 @@
import React, { ReactNode } from "react";
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = "info" | "warning";
@@ -85,6 +86,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
closeIconColor,
compact = false,
}) => {
const { t } = useTranslation();
if (!show) {
return null;
}
@@ -192,7 +194,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
color={closeIconColor ? undefined : "gray"}
size="sm"
onClick={handleDismiss}
aria-label="Dismiss"
aria-label={t("infoBanner.dismiss", "Dismiss")}
style={closeIconColor ? { color: closeIconColor } : undefined}
>
<LocalIcon
@@ -1,4 +1,5 @@
import React, { forwardRef } from "react";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import styles from "@app/components/shared/textInput/TextInput.module.css";
@@ -63,6 +64,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
},
ref,
) => {
const { t } = useTranslation();
const handleClear = () => {
if (onClear) {
onClear();
@@ -112,7 +114,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
type="button"
className={styles.clearButton}
onClick={handleClear}
aria-label="Clear input"
aria-label={t("textInput.clear", "Clear input")}
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
@@ -6,6 +6,7 @@ import React, {
useCallback,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { addEventListenerWithCleanup } from "@app/utils/genericUtils";
import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
@@ -68,6 +69,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
manualCloseOnly = false,
showCloseButton = false,
}) => {
const { t } = useTranslation();
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const { tooltipLogo } = useLogoAssets();
@@ -401,8 +403,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
setIsPinned(false);
setOpen(false);
}}
title="Close tooltip"
aria-label="Close tooltip"
title={t("tooltip.close", "Close tooltip")}
aria-label={t("tooltip.close", "Close tooltip")}
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
@@ -259,7 +259,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
onClick={onClose}
size="lg"
variant="subtle"
aria-label="Close update modal"
aria-label={t("update.closeModal", "Close update modal")}
/>
)}
</Group>
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
import { ActionIcon, Slider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useViewer } from "@app/contexts/ViewerContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
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".
*/
export function ViewerInlineControls() {
const { t } = useTranslation();
const { workbench } = useNavigationState();
const viewer = useViewer();
@@ -39,7 +41,7 @@ export function ViewerInlineControls() {
radius="md"
className="workbench-bar-action-icon"
onClick={() => viewer.zoomActions.zoomOut()}
aria-label="Zoom out"
aria-label={t("viewer.zoomOut", "Zoom out")}
>
<ZoomOutIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -68,7 +70,7 @@ export function ViewerInlineControls() {
radius="md"
className="workbench-bar-action-icon"
onClick={() => viewer.zoomActions.zoomIn()}
aria-label="Zoom in"
aria-label={t("viewer.zoomIn", "Zoom in")}
>
<ZoomInIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -15,6 +15,7 @@
import React, { useEffect, useRef, useState } from "react";
import { ActionIcon, Divider } from "@mantine/core";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import {
useNavigationState,
@@ -37,6 +38,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
setActiveButton,
tooltipPosition = "right",
}) => {
const { t } = useTranslation();
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } =
useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
@@ -158,7 +160,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
<div className="current-tool-content">
<div className="flex flex-col items-center gap-1">
<Tooltip
content={isBackHover ? "Back to all tools" : indicatorTool.name}
content={
isBackHover
? t("quickAccess.backToAllTools", "Back to all tools")
: indicatorTool.name
}
position={tooltipPosition}
arrow
maxWidth={140}
@@ -183,7 +189,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
aria-label={
isBackHover ? "Back to all tools" : indicatorTool.name
isBackHover
? t("quickAccess.backToAllTools", "Back to all tools")
: indicatorTool.name
}
style={{
backgroundColor: isBackHover
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useToast } from "@app/components/toast/ToastContext";
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
import { LocalIcon } from "@app/components/shared/LocalIcon";
@@ -38,6 +39,7 @@ function getDefaultIconName(t: ToastInstance): string {
}
export default function ToastRenderer() {
const { t: translate } = useTranslation();
const { toasts, dismiss } = useToast();
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>(
@@ -88,7 +90,10 @@ export default function ToastRenderer() {
<div className="toast-controls">
{t.expandable && (
<button
aria-label="Toggle details"
aria-label={translate(
"toast.toggleDetails",
"Toggle details",
)}
onClick={() => {
const evt = new CustomEvent("toast:toggle", {
detail: { id: t.id },
@@ -101,7 +106,7 @@ export default function ToastRenderer() {
</button>
)}
<button
aria-label="Dismiss"
aria-label={translate("toast.dismiss", "Dismiss")}
onClick={() => dismiss(t.id)}
className="toast-button"
>
@@ -321,7 +321,7 @@ export default function AutomationEntry({
return shouldShowTooltip ? (
<Tooltip
content={createTooltipContent()}
position="right"
position="left"
arrow={true}
delay={500}
>
@@ -99,11 +99,14 @@ const CompressSettings = ({
{parameters.compressionMethod === "filesize" && (
<Stack gap="sm">
<Text size="sm" fw={500}>
Desired File Size
{t("compress.settings.desiredSize", "Desired File Size")}
</Text>
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
<NumberInput
placeholder="Enter size"
placeholder={t(
"compress.settings.desiredSizePlaceholder",
"Enter size",
)}
value={parameters.fileSizeValue}
onChange={(value) =>
onParameterChange("fileSizeValue", value?.toString() || "")
@@ -139,7 +139,9 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
return (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Loader size="xs" />
<Text size="sm">Loading available languages...</Text>
<Text size="sm">
{t("ocr.languagePicker.loading", "Loading available languages...")}
</Text>
</div>
);
}
@@ -75,7 +75,7 @@ export default function PageLayoutMarginsBordersSettings({
<Stack gap="sm">
<NumberInput
label={t("pageLayout.top", "Top Margin")}
placeholder="Enter top margin"
placeholder={t("pageLayout.margins.topPlaceholder", "Enter top margin")}
value={parameters.topMargin}
onChange={(v) => onParameterChange("topMargin", Number(v))}
min={0}
@@ -85,7 +85,10 @@ export default function PageLayoutMarginsBordersSettings({
/>
<NumberInput
label={t("pageLayout.bottom", "Bottom Margin")}
placeholder="Enter bottom margin"
placeholder={t(
"pageLayout.margins.bottomPlaceholder",
"Enter bottom margin",
)}
value={parameters.bottomMargin}
onChange={(v) => onParameterChange("bottomMargin", Number(v))}
min={0}
@@ -95,7 +98,10 @@ export default function PageLayoutMarginsBordersSettings({
/>
<NumberInput
label={t("pageLayout.left", "Left Margin")}
placeholder="Enter left margin"
placeholder={t(
"pageLayout.margins.leftPlaceholder",
"Enter left margin",
)}
value={parameters.leftMargin}
onChange={(v) => onParameterChange("leftMargin", Number(v))}
min={0}
@@ -104,7 +110,10 @@ export default function PageLayoutMarginsBordersSettings({
/>
<NumberInput
label={t("pageLayout.right", "Right Margin")}
placeholder="Enter right margin"
placeholder={t(
"pageLayout.margins.rightPlaceholder",
"Enter right margin",
)}
value={parameters.rightMargin}
onChange={(v) => onParameterChange("rightMargin", Number(v))}
min={0}
@@ -114,7 +123,10 @@ export default function PageLayoutMarginsBordersSettings({
/>
<NumberInput
label={t("pageLayout.innerMargin", "Inner Margin")}
placeholder="Enter inner margin"
placeholder={t(
"pageLayout.margins.innerPlaceholder",
"Enter inner margin",
)}
value={parameters.innerMargin}
onChange={(v) => onParameterChange("innerMargin", Number(v))}
min={0}
@@ -137,7 +149,10 @@ export default function PageLayoutMarginsBordersSettings({
{parameters.addBorder && (
<NumberInput
label={t("pageLayout.borderWidth", "Border Thickness")}
placeholder="Enter border thickness"
placeholder={t(
"pageLayout.margins.borderThicknessPlaceholder",
"Enter border thickness",
)}
value={parameters.borderWidth}
onChange={(v) => onParameterChange("borderWidth", Number(v))}
min={1}
@@ -80,7 +80,7 @@ export default function PageLayoutSettings({
<>
<NumberInput
label={t("pageLayout.rows", "Rows")}
placeholder="Enter rows"
placeholder={t("pageLayout.rowsPlaceholder", "Enter rows")}
value={parameters.rows}
onChange={(v) => onParameterChange("rows", Number(v))}
min={1}
@@ -90,7 +90,7 @@ export default function PageLayoutSettings({
<NumberInput
label={t("pageLayout.cols", "Columns")}
placeholder="Enter columns"
placeholder={t("pageLayout.colsPlaceholder", "Enter columns")}
value={parameters.cols}
onChange={(v) => onParameterChange("cols", Number(v))}
min={1}
@@ -353,7 +353,7 @@ export const SavedSignaturesSection = ({
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
aria-label={t("sign.saved.use", "Use signature")}
onClick={() => onUseSignature(activePersonalSignature)}
disabled={disabled}
>
@@ -480,7 +480,7 @@ export const SavedSignaturesSection = ({
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
aria-label={t("sign.saved.use", "Use signature")}
onClick={() => onUseSignature(activeSharedSignature)}
disabled={disabled}
>
@@ -618,7 +618,7 @@ export const SavedSignaturesSection = ({
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
aria-label={t("sign.saved.use", "Use signature")}
onClick={() =>
onUseSignature(activeLocalStorageSignature)
}
@@ -32,16 +32,16 @@ export const usePageSelectionTips = (): TooltipContent => {
),
bullets: [
t(
"bulkSelection.operators.and",
'AND: & or "and" require both conditions (e.g., 1-50 & even)',
"bulkSelection.operators.descriptions.and",
'AND: & or "and" - require both conditions (e.g., 1-50 & even)',
),
t(
"bulkSelection.operators.comma",
"Comma: , or | combine selections (e.g., 1-10, 20)",
"bulkSelection.operators.descriptions.comma",
"Comma: , or | - combine selections (e.g., 1-10, 20)",
),
t(
"bulkSelection.operators.not",
'NOT: ! or "not" exclude pages (e.g., 3n & not 30)',
"bulkSelection.operators.descriptions.not",
'NOT: ! or "not" - exclude pages (e.g., 3n & not 30)',
),
],
},
@@ -1176,7 +1176,12 @@ const EmbedPdfViewerContent = ({
{!effectiveFile ? (
<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>
) : isCurrentFileEncrypted ? (
<Center style={{ flex: 1 }}>
@@ -1,4 +1,5 @@
import React, { useCallback, useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useDocumentState } from "@embedpdf/core/react";
import { useScroll } from "@embedpdf/plugin-scroll/react";
import { useAnnotation } from "@embedpdf/plugin-annotation/react";
@@ -143,6 +144,7 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
onMouseEnter,
onMouseLeave,
}) => {
const { t } = useTranslation();
const centerX =
(annotationLink.rect.origin.x + annotationLink.rect.size.width / 2) *
scale;
@@ -170,8 +172,8 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
e.stopPropagation();
onDelete(annotationLink);
}}
aria-label="Delete link"
title="Delete link"
aria-label={t("viewer.link.delete", "Delete link")}
title={t("viewer.link.delete", "Delete link")}
>
<TrashIcon />
</button>
@@ -6,6 +6,7 @@ import {
forwardRef,
useRef,
} from "react";
import { useTranslation } from "react-i18next";
import { createPluginRegistration } from "@embedpdf/core";
import type { PluginRegistry } from "@embedpdf/core";
import { EmbedPDF } from "@embedpdf/core/react";
@@ -157,6 +158,7 @@ export const LocalEmbedPDFWithAnnotations = forwardRef<
},
ref,
) => {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const annotationApiRef = 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
style={{ fontSize: "0.8rem" }}
@@ -139,7 +139,7 @@ function RedactionSelectionMenuInner({
}}
>
<Group gap="sm" wrap="nowrap" justify="center">
<Tooltip label="Remove this mark">
<Tooltip label={t("viewer.redaction.removeMark", "Remove this mark")}>
<ActionIcon
variant="subtle"
color="gray"
@@ -203,7 +203,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
variant="subtle"
size="sm"
onClick={handleCloseClick}
aria-label="Close search"
aria-label={t("viewer.search.close", "Close search")}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
@@ -227,7 +227,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
<ActionIcon
variant="subtle"
onClick={handleClearSearch}
aria-label="Clear search"
aria-label={t("viewer.search.clear", "Clear search")}
>
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
</ActionIcon>
@@ -267,7 +267,9 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
disabled={!resultInfo || resultInfo.totalResults === 0}
/>
<Text size="sm" c="dimmed">
of {resultInfo?.totalResults || 0}
{t("viewer.search.resultsOf", "of {{total}}", {
total: resultInfo?.totalResults || 0,
})}
</Text>
</Group>
@@ -277,7 +279,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
size="sm"
onClick={handlePrevious}
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" />
</ActionIcon>
@@ -288,7 +290,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
disabled={
!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" />
</ActionIcon>
@@ -34,7 +34,7 @@ interface FormFieldSidebarProps {
}
export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
useTranslation();
const { t } = useTranslation();
const { state, setValue, setActiveField } = useFormFill();
const { fields, activeFieldName, loading } = state;
const activeFieldRef = useRef<HTMLDivElement>(null);
@@ -119,7 +119,7 @@ export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
variant="subtle"
size="sm"
onClick={onToggle}
aria-label="Close sidebar"
aria-label={t("formFill.sidebar.close", "Close sidebar")}
>
<CloseIcon sx={{ fontSize: 16 }} />
</ActionIcon>
@@ -27,6 +27,7 @@ import {
Tooltip,
ActionIcon,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
useFormFill,
useAllFormValues,
@@ -120,6 +121,7 @@ const _MODE_TABS: ModeTabDef[] = [
// ---------------------------------------------------------------------------
const FormFill = (_props: BaseToolProps) => {
const { t } = useTranslation();
const { selectedTool } = useNavigation();
const { selectors, state: fileState } = useFileState();
@@ -488,7 +490,10 @@ const FormFill = (_props: BaseToolProps) => {
{/* Flatten toggle */}
<Switch
label="Flatten after filling"
label={t(
"formFill.flattenAfterFilling",
"Flatten after filling",
)}
checked={flatten}
onChange={(e) => setFlatten(e.currentTarget.checked)}
size="xs"
@@ -507,15 +512,22 @@ const FormFill = (_props: BaseToolProps) => {
loading={saving}
disabled={!formState.isDirty && !flattenChanged}
>
Save
{t("formFill.save", "Save")}
</Button>
<Tooltip label="Re-scan fields" withArrow position="bottom">
<Tooltip
label={t("formFill.rescanFields", "Re-scan fields")}
withArrow
position="bottom"
>
<ActionIcon
variant="light"
size="md"
onClick={handleRefresh}
aria-label="Re-scan form fields"
aria-label={t(
"formFill.rescanFormFields",
"Re-scan form fields",
)}
>
<RefreshIcon sx={{ fontSize: 16 }} />
</ActionIcon>
@@ -39,13 +39,15 @@ export function OverviewHeader() {
</Text>
{user?.email && (
<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>
)}
</div>
{user && (
<Button color="red" variant="filled" onClick={handleLogout}>
Log out
{t("account.overview.logOut", "Log out")}
</Button>
)}
</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}
/>
</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}
/>
</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}
/>
</div>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { isAxiosError } from "axios";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
NumberInput,
@@ -52,6 +53,7 @@ interface DatabaseSettingsData {
export default function AdminDatabaseSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
@@ -462,7 +464,16 @@ export default function AdminDatabaseSection() {
)}
</Text>
</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
</Badge>
</Group>
@@ -698,7 +709,10 @@ export default function AdminDatabaseSection() {
onChange={(value) =>
setSettings({ ...settings, password: value })
}
placeholder="Enter database password"
placeholder={t(
"admin.settings.database.password.placeholder",
"Enter database password",
)}
disabled={!loginEnabled}
/>
</div>
@@ -296,7 +296,10 @@ export default function AdminEndpointsSection() {
}))}
searchable
clearable
placeholder="Select endpoints to disable"
placeholder={t(
"admin.settings.endpoints.toRemove.placeholder",
"Select endpoints to disable",
)}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
@@ -330,7 +333,10 @@ export default function AdminEndpointsSection() {
}))}
searchable
clearable
placeholder="Select groups to disable"
placeholder={t(
"admin.settings.endpoints.groupsToRemove.placeholder",
"Select groups to disable",
)}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
@@ -286,7 +286,10 @@ export default function AdminMailSection() {
onChange={(value) =>
setSettings({ ...settings, password: value })
}
placeholder="Enter SMTP password"
placeholder={t(
"admin.settings.mail.password.placeholder",
"Enter SMTP password",
)}
/>
</div>
@@ -183,8 +183,14 @@ const AdminPlanSection: React.FC = () => {
if (!plans || plans.length === 0) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
<Alert
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>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import {
TextInput,
Switch,
@@ -132,17 +132,29 @@ export default function AdminPremiumSection() {
</List.Item>
<List.Item>
<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>
</List.Item>
<List.Item>
<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>
</List.Item>
<List.Item>
<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>
</List.Item>
</List>
@@ -1,4 +1,5 @@
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
NumberInput,
@@ -67,6 +68,7 @@ interface SecuritySettingsData {
export default function AdminSecuritySection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const {
restartModalOpened,
@@ -817,7 +819,16 @@ export default function AdminSecuritySection() {
<Text fw={600} size="sm">
{t("admin.settings.security.audit.label", "Audit Logging")}
</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
</Badge>
</Group>
@@ -11,7 +11,10 @@ import {
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
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 StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
@@ -32,6 +35,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
currentLicenseInfo,
}) => {
const { t } = useTranslation();
const planFeatures = usePlanFeatures();
const planHighlights = usePlanHighlights();
const [showComparison, setShowComparison] = useState(false);
// Static checkout modal state
@@ -88,8 +93,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
price: 0,
currency: "£",
period: "",
highlights: PLAN_HIGHLIGHTS.FREE,
features: PLAN_FEATURES.FREE,
highlights: planHighlights.FREE,
features: planFeatures.FREE,
maxUsers: 5,
},
{
@@ -99,8 +104,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
currency: "",
period: "",
popular: false,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: PLAN_FEATURES.SERVER,
highlights: planHighlights.SERVER_MONTHLY,
features: planFeatures.SERVER,
maxUsers: "Unlimited users",
},
{
@@ -109,8 +114,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
price: 0,
currency: "",
period: "",
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: PLAN_FEATURES.ENTERPRISE,
highlights: planHighlights.ENTERPRISE_MONTHLY,
features: planFeatures.ENTERPRISE,
maxUsers: "Custom",
},
];
@@ -185,14 +185,20 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
return (
<Stack align="center" justify="center" p="xl">
<Loader size="lg" />
<Text c="dimmed">Loading session...</Text>
<Text c="dimmed">
{t("workflow.participant.loadingSession", "Loading session...")}
</Text>
</Stack>
);
}
if (error) {
return (
<Alert icon={<InfoIcon fontSize="small" />} color="red" title="Error">
<Alert
icon={<InfoIcon fontSize="small" />}
color="red"
title={t("workflow.participant.errorTitle", "Error")}
>
{error}
</Alert>
);
@@ -201,7 +207,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
if (!session || !participant) {
return (
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
Session not found or access denied.
{t(
"workflow.participant.sessionNotFound",
"Session not found or access denied.",
)}
</Alert>
);
}
@@ -209,15 +218,35 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const getStatusBadge = (status: string) => {
switch (status) {
case "SIGNED":
return <Badge color="green">Signed</Badge>;
return (
<Badge color="green">
{t("workflow.participant.statusSigned", "Signed")}
</Badge>
);
case "DECLINED":
return <Badge color="red">Declined</Badge>;
return (
<Badge color="red">
{t("workflow.participant.statusDeclined", "Declined")}
</Badge>
);
case "VIEWED":
return <Badge color="blue">Viewed</Badge>;
return (
<Badge color="blue">
{t("workflow.participant.statusViewed", "Viewed")}
</Badge>
);
case "NOTIFIED":
return <Badge color="yellow">Notified</Badge>;
return (
<Badge color="yellow">
{t("workflow.participant.statusNotified", "Notified")}
</Badge>
);
case "PENDING":
return <Badge color="gray">Pending</Badge>;
return (
<Badge color="gray">
{t("workflow.participant.statusPending", "Pending")}
</Badge>
);
default:
return <Badge>{status}</Badge>;
}
@@ -254,7 +283,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{session.documentName}
</Text>
<Text size="sm" c="dimmed">
From: {session.ownerUsername}
{t("workflow.participant.from", "From: {{name}}", {
name: session.ownerUsername,
})}
</Text>
</div>
{getStatusBadge(participant.status)}
@@ -272,7 +303,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{session.dueDate && (
<Text size="sm" c="dimmed">
Due Date: {session.dueDate}
{t("workflow.participant.dueDate", "Due Date: {{date}}", {
date: session.dueDate,
})}
</Text>
)}
@@ -283,7 +316,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
onClick={() => downloadDocument(token)}
variant="light"
>
Download Document
{t("workflow.participant.downloadDocument", "Download Document")}
</Button>
</Group>
</Stack>
@@ -293,17 +326,35 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
<Card shadow="sm" padding="md" radius="md" withBorder>
<Stack gap="md">
<Text fw={500} size="lg">
Sign Document
{t("workflow.participant.signDocument", "Sign Document")}
</Text>
<Select
label="Certificate Type"
label={t(
"workflow.participant.certificateType",
"Certificate Type",
)}
value={certType}
onChange={(value) => setCertType(value || "P12")}
data={[
{ value: "P12", label: "P12/PKCS12 Certificate" },
{ value: "JKS", label: "JKS Keystore" },
{ value: "SERVER", label: "Server Certificate (if available)" },
{
value: "P12",
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"
data-testid="cert-type-select"
@@ -312,8 +363,14 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{certType !== "SERVER" && (
<>
<FileInput
label="Certificate File"
placeholder="Select certificate file"
label={t(
"workflow.participant.certificateFile",
"Certificate File",
)}
placeholder={t(
"workflow.participant.certificateFilePlaceholder",
"Select certificate file",
)}
value={certFile}
onChange={setCertFile}
accept=".p12,.pfx,.jks"
@@ -322,7 +379,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
/>
<TextInput
label="Certificate Password"
label={t(
"workflow.participant.certificatePassword",
"Certificate Password",
)}
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
@@ -386,23 +446,32 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
)}
<TextInput
label="Location"
placeholder="e.g., San Francisco, CA"
label={t("workflow.participant.location", "Location")}
placeholder={t(
"workflow.participant.locationPlaceholder",
"e.g., San Francisco, CA",
)}
value={location}
onChange={(e) => setLocation(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Reason"
placeholder="e.g., Document approval"
label={t("workflow.participant.reason", "Reason")}
placeholder={t(
"workflow.participant.reasonPlaceholder",
"e.g., Document approval",
)}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Page Number (optional)"
label={t(
"workflow.participant.pageNumber",
"Page Number (optional)",
)}
type="number"
value={pageNumber}
onChange={(e) =>
@@ -423,7 +492,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
color="green"
data-testid="submit-signature-button"
>
Submit Signature
{t("workflow.participant.submitSignature", "Submit Signature")}
</Button>
<Button
@@ -433,7 +502,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
variant="light"
data-testid="decline-button"
>
Decline
{t("workflow.participant.decline", "Decline")}
</Button>
</Group>
</Stack>
@@ -442,14 +511,24 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{participant.hasCompleted && (
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
You have {participant.status === "SIGNED" ? "signed" : "declined"}{" "}
this document.
{participant.status === "SIGNED"
? t(
"workflow.participant.completedSigned",
"You have signed this document.",
)
: t(
"workflow.participant.completedDeclined",
"You have declined this document.",
)}
</Alert>
)}
{participant.isExpired && (
<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>
)}
</Stack>
@@ -1,100 +1,219 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { PlanFeature } from "@app/types/license";
/**
* 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 = {
FREE: [
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "up to 5 users", included: true },
{ name: "Unlimited users", included: false },
{ name: "Google drive integration", included: false },
{ name: "External Database", included: false },
{ name: "Editing text in pdfs", included: false },
{ name: "Users limited to seats", included: false },
{ name: "SSO", included: false },
{ name: "SAML", included: false },
{ name: "Auditing", included: false },
{ name: "Usage tracking", included: false },
{ name: "Prometheus Support", included: false },
{ name: "Custom PDF metadata", included: false },
] as PlanFeature[],
export interface PlanFeaturesMap {
FREE: PlanFeature[];
SERVER: PlanFeature[];
ENTERPRISE: PlanFeature[];
}
SERVER: [
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "Up to 5 users", included: false },
{ name: "Unlimited users", included: true },
{ name: "Google drive integration", included: true },
{ name: "External Database", included: true },
{ name: "Editing text in pdfs", included: true },
{ name: "Users limited to seats", included: false },
{ name: "SSO", included: true },
{ name: "SAML", included: false },
{ name: "Auditing", included: false },
{ name: "Usage tracking", included: false },
{ name: "Prometheus Support", included: false },
{ name: "Custom PDF metadata", included: false },
] as PlanFeature[],
export interface PlanHighlightsMap {
FREE: string[];
SERVER_MONTHLY: string[];
SERVER_YEARLY: string[];
ENTERPRISE_MONTHLY: string[];
ENTERPRISE_YEARLY: string[];
}
ENTERPRISE: [
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "up to 5 users", included: false },
{ name: "Unlimited users", included: false },
{ name: "Google drive integration", included: true },
{ name: "External Database", included: true },
{ name: "Editing text in pdfs", included: true },
{ name: "Users limited to seats", included: true },
{ name: "SSO", included: true },
{ name: "SAML", included: true },
{ name: "Auditing", included: true },
{ name: "Usage tracking", included: true },
{ name: "Prometheus Support", included: true },
{ name: "Custom PDF metadata", included: true },
] as PlanFeature[],
} as const;
export const usePlanFeatures = (): PlanFeaturesMap => {
const { t } = useTranslation();
export const PLAN_HIGHLIGHTS = {
FREE: ["Up to 5 users", "Self-hosted", "All basic features"],
SERVER_MONTHLY: [
"Self-hosted on your infrastructure",
"Unlimited users",
"Advanced integrations",
"SSO (OAuth2/OIDC)",
"Editing text in PDFs",
"Cancel anytime",
],
SERVER_YEARLY: [
"Self-hosted on your infrastructure",
"Unlimited users",
"Advanced integrations",
"SSO (OAuth2/OIDC)",
"Editing text in PDFs",
"Save with annual billing",
],
ENTERPRISE_MONTHLY: [
"Enterprise features (SAML, Auditing)",
"Usage tracking & Prometheus",
"Custom PDF metadata",
"Per-seat licensing",
],
ENTERPRISE_YEARLY: [
"Enterprise features (SAML, Auditing)",
"Usage tracking & Prometheus",
"Custom PDF metadata",
"Save with annual billing",
],
} as const;
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: [
{ name: selfHostedDeployment, included: true },
{ name: allPdfOperations, included: true },
{ name: secureLoginSupport, included: true },
{ name: communitySupport, included: true },
{ name: regularUpdates, included: true },
{ name: upToFiveUsersLowercase, included: true },
{ name: unlimitedUsers, included: false },
{ name: googleDriveIntegration, included: false },
{ name: externalDatabase, included: false },
{ name: editingTextInPdfs, included: false },
{ name: usersLimitedToSeats, included: false },
{ name: sso, included: false },
{ name: saml, included: false },
{ name: auditing, included: false },
{ name: usageTracking, included: false },
{ name: prometheusSupport, included: false },
{ name: customPdfMetadata, included: false },
],
SERVER: [
{ name: selfHostedDeployment, included: true },
{ name: allPdfOperations, included: true },
{ name: secureLoginSupport, included: true },
{ name: communitySupport, included: true },
{ name: regularUpdates, included: true },
{ name: upToFiveUsers, included: false },
{ name: unlimitedUsers, included: true },
{ name: googleDriveIntegration, included: true },
{ name: externalDatabase, included: true },
{ name: editingTextInPdfs, included: true },
{ name: usersLimitedToSeats, included: false },
{ name: sso, included: true },
{ name: saml, included: false },
{ name: auditing, included: false },
{ name: usageTracking, included: false },
{ name: prometheusSupport, included: false },
{ name: customPdfMetadata, included: false },
],
ENTERPRISE: [
{ name: selfHostedDeployment, included: true },
{ name: allPdfOperations, included: true },
{ name: secureLoginSupport, included: true },
{ name: communitySupport, included: true },
{ name: regularUpdates, included: true },
{ name: upToFiveUsersLowercase, included: false },
{ name: unlimitedUsers, included: false },
{ name: googleDriveIntegration, included: true },
{ name: externalDatabase, included: true },
{ name: editingTextInPdfs, included: true },
{ name: usersLimitedToSeats, included: true },
{ name: sso, included: true },
{ name: saml, included: true },
{ name: auditing, included: true },
{ name: usageTracking, included: true },
{ name: prometheusSupport, included: true },
{ name: customPdfMetadata, included: true },
],
};
}, [t]);
};
export const usePlanHighlights = (): PlanHighlightsMap => {
const { t } = useTranslation();
return useMemo(() => {
const selfHostedOnInfrastructure = t(
"plan.highlights.selfHostedOnInfrastructure",
"Self-hosted on your infrastructure",
);
const unlimitedUsers = t(
"plan.highlights.unlimitedUsers",
"Unlimited users",
);
const advancedIntegrations = t(
"plan.highlights.advancedIntegrations",
"Advanced integrations",
);
const ssoOAuth = t("plan.highlights.ssoOAuth", "SSO (OAuth2/OIDC)");
const editingTextInPdfsCaps = t(
"plan.highlights.editingTextInPdfsCaps",
"Editing text in PDFs",
);
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: [
selfHostedOnInfrastructure,
unlimitedUsers,
advancedIntegrations,
ssoOAuth,
editingTextInPdfsCaps,
saveWithAnnualBilling,
],
ENTERPRISE_MONTHLY: [
enterpriseFeatures,
usageTrackingPrometheus,
customPdfMetadata,
t("plan.highlights.perSeatLicensing", "Per-seat licensing"),
],
ENTERPRISE_YEARLY: [
enterpriseFeatures,
usageTrackingPrometheus,
customPdfMetadata,
saveWithAnnualBilling,
],
};
}, [t]);
};
@@ -24,6 +24,10 @@ import {
import { useLicense } from "@app/contexts/LicenseContext";
import { isSupabaseConfigured } from "@app/services/supabaseClient";
import { getPreferredCurrency } from "@app/utils/currencyDetection";
import {
usePlanFeatures,
usePlanHighlights,
} from "@app/constants/planConstants";
export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise
@@ -57,6 +61,8 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
}) => {
const { t, i18n } = useTranslation();
const { refetchLicense } = useLicense();
const planFeatures = usePlanFeatures();
const planHighlights = usePlanHighlights();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] =
@@ -85,7 +91,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
try {
setPlansLoading(true);
const response = await licenseService.getPlans(currency);
const response = await licenseService.getPlans(
planFeatures,
planHighlights,
currency,
);
setPlans(response.plans);
setPlansLoaded(true);
} catch (error) {
@@ -95,7 +105,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
setPlansLoading(false);
}
},
[plansLoading],
[plansLoading, planFeatures, planHighlights],
);
const refetchPlans = useCallback(() => {
@@ -1,8 +1,12 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import licenseService, {
PlanTier,
PlansResponse,
} from "@app/services/licenseService";
import {
usePlanFeatures,
usePlanHighlights,
} from "@app/constants/planConstants";
export interface UsePlansReturn {
plans: PlanTier[];
@@ -12,16 +16,22 @@ export interface UsePlansReturn {
}
export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
const planFeatures = usePlanFeatures();
const planHighlights = usePlanHighlights();
const [plans, setPlans] = useState<PlanTier[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPlans = async () => {
const fetchPlans = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data: PlansResponse = await licenseService.getPlans(currency);
const data: PlansResponse = await licenseService.getPlans(
planFeatures,
planHighlights,
currency,
);
setPlans(data.plans);
} catch (err) {
console.error("Error fetching plans:", err);
@@ -29,11 +39,11 @@ export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
} finally {
setLoading(false);
}
};
}, [currency, planFeatures, planHighlights]);
useEffect(() => {
fetchPlans();
}, [currency]);
}, [fetchPlans]);
return {
plans,
@@ -1,7 +1,10 @@
import apiClient from "@app/services/apiClient";
import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient";
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";
export interface PlanTier {
@@ -102,9 +105,18 @@ const SELF_HOSTED_LOOKUP_KEYS = [
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 {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
@@ -179,8 +191,8 @@ const licenseService = {
currency: currencySymbol,
period: "/month",
popular: false,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: planFeatures.SERVER,
highlights: planHighlights.SERVER_MONTHLY,
},
{
id: "selfhosted:server:yearly",
@@ -190,8 +202,8 @@ const licenseService = {
currency: currencySymbol,
period: "/year",
popular: true,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_YEARLY,
features: planFeatures.SERVER,
highlights: planHighlights.SERVER_YEARLY,
},
{
id: "selfhosted:enterprise:monthly",
@@ -203,8 +215,8 @@ const licenseService = {
period: "/month",
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: planFeatures.ENTERPRISE,
highlights: planHighlights.ENTERPRISE_MONTHLY,
},
{
id: "selfhosted:enterprise:yearly",
@@ -216,8 +228,8 @@ const licenseService = {
period: "/year",
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_YEARLY,
features: planFeatures.ENTERPRISE,
highlights: planHighlights.ENTERPRISE_YEARLY,
},
];
@@ -240,8 +252,8 @@ const licenseService = {
currency: currencySymbol,
period: "",
popular: false,
features: PLAN_FEATURES.FREE,
highlights: PLAN_HIGHLIGHTS.FREE,
features: planFeatures.FREE,
highlights: planHighlights.FREE,
};
const allPlans = [freePlan, ...validPlans];