Merge branch 'main' into fix/reject-unteamed-policy-scope

This commit is contained in:
EthanHealy01
2026-07-07 14:52:21 +01:00
committed by GitHub
163 changed files with 10275 additions and 1331 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.0
pkgver=2.14.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.0
pkgver=2.14.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+15
View File
@@ -87,6 +87,21 @@ engine: &engine
- Taskfile.yml
- .taskfiles/engine.yml
# Files that can make the committed generated API models (frontend tool API
# types + engine tool models) go stale: the Java tool surfaces they derive from,
# the generators, the generated files themselves (to catch a hand-edit), and the
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
generated-models: &generated-models
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
- engine/scripts/generate_tool_models.py
- engine/src/stirling/models/tool_models.py
- .taskfiles/frontend.yml
- .taskfiles/engine.yml
- .github/workflows/check-generated-models.yml
licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
+4 -99
View File
@@ -1,9 +1,9 @@
name: AI Engine CI
# Validates the Python AI engine: regenerates tool models and runs the
# engine quality gate (lint, type-check, format-check, tests). Called from
# build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net.
# Runs the engine quality gate (lint, type-check, format-check, tests). Called
# from build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net. Freshness of the generated tool_models.py is checked
# by the shared check-generated-models workflow.
on:
workflow_call:
push:
@@ -34,104 +34,9 @@ jobs:
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Regenerate tool models
run: task engine:tool-models
- name: Verify tool models are up to date
id: tool-models-check
continue-on-error: true
run: git diff --exit-code engine/src/stirling/models/tool_models.py
- name: Comment on tool models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const body = [
marker,
'### Tool Models Check Failed',
'',
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
'',
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if tool models check failed
if: steps.tool-models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Tool Models Check Failed"
echo "============================================"
echo ""
echo "The generated engine/src/stirling/models/tool_models.py"
echo "is out of date with the Java OpenAPI spec and will"
echo "need to be regenerated before it can be merged in."
echo ""
echo "Run 'task engine:tool-models' to regenerate, then"
echo "commit the updated file."
echo "============================================"
exit 1
- name: Remove tool models check comment on success
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
- name: Quality-check engine
id: engine-check
run: task engine:check
+19
View File
@@ -167,6 +167,8 @@ jobs:
wait_for_backend
- name: Run enterprise OAuth Playwright tests
id: oauth-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
run: task e2e:enterprise -- --grep "OAuth"
- name: Stop backend + tear down OAuth Keycloak
if: always()
@@ -240,6 +242,8 @@ jobs:
wait_for_backend
- name: Run enterprise SAML Playwright tests
id: saml-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
run: task e2e:enterprise -- --grep "SAML"
- name: Stop backend + tear down SAML Keycloak
if: always()
@@ -270,6 +274,8 @@ jobs:
wait_for_backend
- name: Run enterprise feature Playwright tests
id: feature-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
run: task e2e:enterprise -- --grep "Enterprise license"
- name: Print backend log on failure
if: failure()
@@ -282,6 +288,19 @@ jobs:
run: |
source /tmp/helpers.sh
stop_backend
- name: Flag flaky tests
# Runs regardless of the test outcomes: a flaky test (passed on retry)
# leaves its step green, so this is the only place it surfaces. Merges
# all three phase reports (some may be absent if an earlier phase hard-
# failed and skipped the rest). Emits ::warning:: annotations + a job
# summary; never fails the job.
if: always()
working-directory: frontend
run: >
npx tsx editor/scripts/report-flaky-tests.mts
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+17
View File
@@ -43,6 +43,7 @@ jobs:
docker-base: ${{ steps.changes.outputs.docker-base }}
tauri: ${{ steps.changes.outputs.tauri }}
engine: ${{ steps.changes.outputs.engine }}
generated-models: ${{ steps.changes.outputs.generated-models }}
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
@@ -171,6 +172,20 @@ jobs:
uses: ./.github/workflows/ai-engine.yml
secrets: inherit
# The generated frontend types and engine tool models are both derived from
# the Java OpenAPI spec. This job regenerates and diffs them; it boots the
# backend, so it is gated on the narrow generated-models filter (spec source,
# generators, generated files, generation tasks) rather than the broad
# frontend filter, so a CSS-only PR does not pay for a backend build.
generated-models:
if: needs.files-changed.outputs.generated-models == 'true'
needs: [files-changed]
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
pre-commit:
needs: [files-changed]
permissions:
@@ -228,6 +243,7 @@ jobs:
- test-build-docker-images
- tauri-build
- ai-engine
- generated-models
- pre-commit
- dependency-review
runs-on: ubuntu-latest
@@ -253,6 +269,7 @@ jobs:
test-build-docker-images=${{ needs.test-build-docker-images.result }}
tauri-build=${{ needs.tauri-build.result }}
ai-engine=${{ needs.ai-engine.result }}
generated-models=${{ needs.generated-models.result }}
pre-commit=${{ needs.pre-commit.result }}
dependency-review=${{ needs.dependency-review.result }}
run: |
@@ -0,0 +1,148 @@
name: Check generated models
# Verifies the committed generated API models are still in sync with the Java
# OpenAPI spec: the frontend tool API types
# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
# single top-level `task tool-models` and fails if either committed file is
# out of date. Called from build.yml when the backend Java, frontend, or engine
# changes; also runs on push to main as a post-merge safety net.
on:
workflow_call:
push:
branches: [main]
permissions:
contents: read
jobs:
generated-models:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# Rebuilds the OpenAPI spec from the current Java and regenerates both the
# frontend types and the engine tool models from it.
- name: Regenerate generated models
run: task tool-models
- name: Verify generated models are up to date
id: models-check
continue-on-error: true
run: |
git diff --exit-code \
frontend/editor/src/core/types/toolApiTypes.ts \
engine/src/stirling/models/tool_models.py
- name: Comment on generated models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const body = [
marker,
'### Generated Models Check Failed',
'',
'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
'',
'Run `task tool-models` to regenerate both, then commit the updated files.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if generated models check failed
if: steps.models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Generated Models Check Failed"
echo "============================================"
echo ""
echo "The generated frontend API types and/or engine tool"
echo "models are out of date with the Java OpenAPI spec and"
echo "will need to be regenerated before they can be merged in."
echo ""
echo "Run 'task tool-models' to regenerate both, then"
echo "commit the updated files."
echo "============================================"
exit 1
- name: Remove generated models check comment on success
if: steps.models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
+10
View File
@@ -62,7 +62,17 @@ jobs:
# .test-state/playwright/coverage-pw/ for the post-process step
# to aggregate. Chromium-only - other engines silently skip.
PW_COVERAGE: "1"
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:live
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Generate JaCoCo report from e2e:live .exec
if: always()
id: live-coverage
+11
View File
@@ -44,7 +44,18 @@ jobs:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+17
View File
@@ -396,6 +396,23 @@ tasks:
# Code Generation
# ============================================================
tool-models:
desc: "Generate tool API types from the Java OpenAPI spec"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
generates:
- editor/src/core/types/toolApiTypes.ts
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
licenses:generate:
desc: "Generate frontend license report"
deps: [install]
+10
View File
@@ -185,6 +185,16 @@ tasks:
- task: frontend:format:check
- task: engine:format:check
# ============================================================
# Code generation
# ============================================================
tool-models:
desc: "Generate all API models from the Java OpenAPI spec"
cmds:
- task: frontend:tool-models
- task: engine:tool-models
# ============================================================
# Quality Gate
# ============================================================
@@ -1,5 +1,7 @@
package stirling.software.SPDF.model.api.general;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -17,20 +19,8 @@ public class PosterPdfRequest extends PDFFile {
allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"})
private String pageSize = "A4";
@Schema(
description = "Horizontal decimation factor (how many columns to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int xFactor = 2;
@Schema(
description = "Vertical decimation factor (how many rows to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int yFactor = 2;
@Schema(
@@ -38,4 +28,36 @@ public class PosterPdfRequest extends PDFFile {
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean rightToLeft = false;
@JsonProperty("xFactor")
@Schema(
description = "Horizontal decimation factor (how many columns to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
public int getXFactor() {
return xFactor;
}
@JsonProperty("xFactor")
public void setXFactor(int xFactor) {
this.xFactor = xFactor;
}
@JsonProperty("yFactor")
@Schema(
description = "Vertical decimation factor (how many rows to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
public int getYFactor() {
return yFactor;
}
@JsonProperty("yFactor")
public void setYFactor(int yFactor) {
this.yFactor = yFactor;
}
}
@@ -29,7 +29,8 @@ public class AddPasswordRequest extends PDFFile {
description = "The length of the encryption key",
type = "integer",
allowableValues = {"40", "128", "256"},
requiredMode = Schema.RequiredMode.REQUIRED)
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "256")
private int keyLength = 256;
@Schema(description = "Whether document assembly is prevented", defaultValue = "false")
@@ -21,8 +21,7 @@ public enum AiWorkflowOutcome {
COMPLETED("completed"),
UNSUPPORTED_CAPABILITY("unsupported_capability"),
CANNOT_CONTINUE("cannot_continue"),
GENERATE_FILE("generate_file"),
CONVERT_MARKDOWN("convert_markdown");
GENERATE_FILE("generate_file");
private final String value;
@@ -68,7 +68,6 @@ import tools.jackson.databind.ObjectMapper;
public class AiWorkflowService {
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
private static final String PDF_TO_MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final AiEngineClient aiEngineClient;
@@ -196,7 +195,6 @@ public class AiWorkflowService {
return switch (response.getOutcome()) {
case NEED_CONTENT -> onNeedContent(response, filesById, request, listener);
case NEED_INGEST -> onNeedIngest(response, filesById, request, listener);
case CONVERT_MARKDOWN -> onConvertMarkdown(response, filesById, listener);
case TOOL_CALL -> onToolCall(response, filesById, listener);
case PLAN -> onPlan(response, filesById, request, listener);
case ANSWER -> onAnswer(response, filesById, request, listener);
@@ -333,72 +331,6 @@ public class AiWorkflowService {
return new WorkflowState.Pending(nextRequest);
}
/**
* Deterministically convert each requested PDF to Markdown via the {@code
* /convert/pdf/markdown} endpoint (backed by {@code PdfMarkdownConverter}) and return the
* {@code .md} file(s) as a completed result. No AI resume — the conversion output is the final
* answer.
*/
private WorkflowState onConvertMarkdown(
AiWorkflowResponse response,
Map<String, MultipartFile> filesById,
ProgressListener listener) {
List<AiFile> filesToConvert = response.getFilesToIngest();
if (filesToConvert == null || filesToConvert.isEmpty()) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested markdown conversion without listing any files."));
}
try {
List<Resource> resultFiles = new ArrayList<>();
List<String> inputNames = new ArrayList<>();
for (int i = 0; i < filesToConvert.size(); i++) {
AiFile file = filesToConvert.get(i);
MultipartFile multipartFile = filesById.get(file.getId());
if (multipartFile == null) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested markdown conversion for unknown file: "
+ file.getName()));
}
listener.onProgress(
AiWorkflowProgressEvent.executingTool(
PDF_TO_MARKDOWN_ENDPOINT, i + 1, filesToConvert.size()));
Resource input = toResource(multipartFile);
PipelineDefinition definition =
new PipelineDefinition(
"convert-markdown",
List.of(new PipelineStep(PDF_TO_MARKDOWN_ENDPOINT, Map.of())),
null);
PolicyExecutionResult result =
policyExecutor.execute(
definition,
PolicyInputs.of(List.of(input)),
PolicyProgressListener.NOOP);
resultFiles.addAll(result.files());
inputNames.add(multipartFile.getOriginalFilename());
}
return new WorkflowState.Terminal(
buildCompletedResponse(null, resultFiles, inputNames, null));
} catch (InternalApiTimeoutException e) {
log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
return new WorkflowState.Terminal(
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
} catch (Exception e) {
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
if (limit != null) {
log.info(
"AI markdown conversion blocked by downstream entitlement gate ({})",
limit.getErrorCode());
return new WorkflowState.Terminal(limit);
}
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
return new WorkflowState.Terminal(
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
}
}
private Resource toResource(MultipartFile file) throws IOException {
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
file.transferTo(tempFile.getPath());
@@ -221,34 +221,6 @@ class AiWorkflowServiceMoreTest {
}
}
@Nested
@DisplayName("convert_markdown guards")
class ConvertMarkdownGuards {
@Test
@DisplayName("no files listed yields CANNOT_CONTINUE")
void noFiles() throws IOException {
stubOrchestrator("{\"outcome\":\"convert_markdown\",\"filesToIngest\":[]}");
AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "to md"));
assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
}
@Test
@DisplayName("unknown file id yields CANNOT_CONTINUE")
void unknownFile() throws IOException {
when(fileIdStrategy.idFor(any())).thenReturn("real-id");
stubOrchestrator(
"""
{"outcome":"convert_markdown",
"filesToIngest":[{"id":"other-id","name":"other.pdf"}]}
""");
AiWorkflowResponse result =
service.orchestrate(requestFor(pdf("real.pdf", "x"), "to md"));
assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
assertThat(result.getReason()).contains("other.pdf");
}
}
@Nested
@DisplayName("plan guards and errors")
class PlanGuardsAndErrors {
@@ -78,6 +78,7 @@ class AiWorkflowServiceTest {
private static final String SPLIT_ENDPOINT = "/api/v1/general/split-pages";
private static final String MERGE_ENDPOINT = "/api/v1/general/merge-pdfs";
private static final String COMPRESS_ENDPOINT = "/api/v1/misc/compress-pdf";
private static final String MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private AiEngineClient aiEngineClient;
@@ -440,23 +441,23 @@ class AiWorkflowServiceTest {
}
@Test
void convertMarkdownRunsDeterministicConversionAndReturnsMdFile() throws IOException {
void planWithMarkdownStepReturnsMdFile() throws IOException {
// PDF→Markdown is a normal tool the edit agent emits as a plan step (no bespoke
// outcome); the plan executor runs the converter and returns the .md file.
MockMultipartFile input = pdf("multi-column-test_lorem.pdf", "pdf-bytes");
when(fileIdStrategy.idFor(any())).thenReturn("doc-1");
stubOrchestrator(
"""
{
"outcome":"convert_markdown",
"reason":"PDF to Markdown requested.",
"filesToIngest":[{"id":"doc-1","name":"multi-column-test_lorem.pdf"}]
"outcome":"plan",
"summary":"Convert to Markdown",
"steps":[{"tool":"%s","parameters":{}}]
}
""");
when(toolMetadataService.shouldUnpackZipResponse("/api/v1/convert/pdf/markdown"))
.thenReturn(false);
stubEndpoint(
"/api/v1/convert/pdf/markdown",
pdfResource("# Title", "multi-column-test_lorem.md"));
AtomicInteger ids = stubFileStorage();
"""
.formatted(MARKDOWN_ENDPOINT));
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
stubEndpoint(MARKDOWN_ENDPOINT, pdfResource("# Title", "multi-column-test_lorem.md"));
stubFileStorage();
AiWorkflowResponse result = service.orchestrate(requestFor(input, "convert to markdown"));
@@ -464,8 +465,7 @@ class AiWorkflowServiceTest {
assertEquals(1, result.getResultFiles().size());
// Extension changes (pdf -> md), so the converter's response filename wins.
assertEquals("multi-column-test_lorem.md", result.getResultFiles().get(0).getFileName());
assertEquals(1, ids.get());
verify(internalApiClient, times(1)).post(eq("/api/v1/convert/pdf/markdown"), any());
verify(internalApiClient, times(1)).post(eq(MARKDOWN_ENDPOINT), any());
}
@Test
@@ -18,13 +18,15 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"stirling.software.saas.repository",
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository"
"stirling.software.saas.payg.repository",
"stirling.software.saas.procurement.repository"
})
@EntityScan({
"stirling.software.saas.accountlink",
"stirling.software.saas.model",
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
"stirling.software.saas.payg"
"stirling.software.saas.payg",
"stirling.software.saas.procurement.model"
})
public class SaasJpaConfig {}
@@ -0,0 +1,321 @@
package stirling.software.saas.procurement.api;
import java.util.List;
import java.util.Objects;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.pricing.QuoteLineItem;
import stirling.software.saas.procurement.service.ProcurementService;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a
* (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a
* Supabase edge function the portal calls with the accepted quote — this controller never touches
* Stripe. The caller's team is resolved from the authenticated principal; a team id is never
* trusted from the request. Mutations require the team leader.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/procurement")
@Profile("saas")
public class ProcurementController {
// Local mapper to parse the stored line-items JSON; the saas context exposes no injectable
// ObjectMapper bean.
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final ProcurementService procurement;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
private final ProcurementConfigurationProperties config;
public ProcurementController(
ProcurementService procurement,
TeamMembershipRepository memberRepo,
UserRepository userRepository,
ProcurementConfigurationProperties config) {
this.procurement = Objects.requireNonNull(procurement);
this.memberRepo = Objects.requireNonNull(memberRepo);
this.userRepository = Objects.requireNonNull(userRepository);
this.config = Objects.requireNonNull(config);
}
// ---- request / response DTOs -------------------------------------------
public record QuoteRequest(
long volume,
int users,
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
String currency,
String businessName) {
QuoteConfig toConfig() {
return new QuoteConfig(
volume,
users,
deployment,
termYears,
serviceLevel,
indemnification,
training,
qbr,
currency);
}
}
public record QuoteResponse(
Long quoteId,
String quoteNumber,
String status,
String currency,
long annualNetMinor,
long tcvMinor,
List<QuoteLineItem> lineItems,
String validUntil,
String stripeQuoteId,
String invoiceUrl,
QuoteConfigEcho config) {}
/**
* The inputs the quote was priced from, echoed back so the builder can seed itself when the
* buyer re-edits an existing quote. {@code users} is not persisted (only the resulting volume
* is), so it is always 0 here; the builder treats the seeded volume as manually set.
*/
public record QuoteConfigEcho(
long volume,
int users,
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
String currency,
String businessName) {}
public record SnapshotResponse(
Long dealId,
String stage,
String trialStartedAt,
String trialEndsAt,
int trialExtensionsUsed,
boolean licensed,
QuoteResponse latestQuote) {}
// ---- endpoints ----------------------------------------------------------
/**
* The team's deal snapshot. Always 200 with a single shape; an unstarted procurement returns an
* empty snapshot ({@code dealId == null}) so the portal can render the "start" state without
* special-casing an empty body.
*/
@GetMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> snapshot(Authentication auth) {
Long teamId = resolveTeam(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return ResponseEntity.ok(
procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
}
private static final SnapshotResponse EMPTY_SNAPSHOT =
new SnapshotResponse(null, null, null, null, 0, false, null);
@PostMapping("/trial/start")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId)));
}
@PostMapping("/trial/extend")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> extendTrial(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
@PostMapping("/quote")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<QuoteResponse> buildQuote(
@RequestBody QuoteRequest request, Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(
toQuote(
procurement.buildQuote(
teamId, request.toConfig(), request.businessName())));
}
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
// draft into a finalized Stripe Quote; accept-procurement-quote accepts it into a subscription.
// Both persist their results via SECURITY DEFINER RPCs; the snapshot above reflects them.
/**
* Advance an issued quote to the agreement (security) stage, where the buyer reviews + agrees.
*/
@PostMapping("/agreement")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> startAgreement(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
/**
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
*/
@PostMapping("/go-live")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> goLive(Authentication auth) {
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
/** Reset the team's procurement (delete the deal + quotes); returns the empty snapshot. */
@PostMapping("/reset")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> reset(Authentication auth) {
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
procurement.resetDeal(teamId);
return ResponseEntity.ok(EMPTY_SNAPSHOT);
}
// ---- helpers ------------------------------------------------------------
/**
* Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
*/
private Long resolveTeam(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return null;
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
}
/** Team id only when the caller is the team leader; null otherwise (commercial actions). */
private Long requireLeader(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return null;
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null;
return rows.get(0).getTeam().getId();
}
private SnapshotResponse toSnapshot(ProcurementDeal deal) {
QuoteResponse latest =
procurement.quotesForDeal(deal.getDealId()).stream()
.findFirst()
.map(this::toQuote)
.orElse(null);
return new SnapshotResponse(
deal.getDealId(),
deal.getStage(),
str(deal.getTrialStartedAt()),
str(deal.getTrialEndsAt()),
deal.getTrialExtensionsUsed(),
deal.getLicenseRef() != null,
latest);
}
private QuoteResponse toQuote(ProcurementQuote q) {
return new QuoteResponse(
q.getQuoteId(),
q.getQuoteNumber(),
q.getStatus(),
q.getCurrency(),
q.getAnnualNetMinor(),
q.getTcvMinor(),
parseLineItems(q.getLineItemsJson()),
q.getValidUntil() == null ? null : q.getValidUntil().toString(),
q.getStripeQuoteId(),
q.getStripeInvoiceUrl(),
new QuoteConfigEcho(
q.getVolume(),
0,
q.getDeployment(),
q.getTermYears(),
q.getServiceLevel(),
q.isIndemnification(),
q.isTraining(),
q.isQbr(),
q.getCurrency(),
q.getBusinessName()));
}
private List<QuoteLineItem> parseLineItems(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return OBJECT_MAPPER.readValue(
json,
OBJECT_MAPPER
.getTypeFactory()
.constructCollectionType(List.class, QuoteLineItem.class));
} catch (Exception e) {
log.warn("[procurement] failed to parse line items", e);
return List.of();
}
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
}
@@ -0,0 +1,33 @@
package stirling.software.saas.procurement.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
/** Tunables for the enterprise procurement flow. Prefix {@code stirling.procurement}. */
@Getter
@Setter
@Component
@Profile("saas")
@ConfigurationProperties(prefix = "stirling.procurement")
public class ProcurementConfigurationProperties {
/** Free trial length, in days (no card). */
private int trialDurationDays = 14;
/** Days added per trial extension. */
private int trialExtensionDays = 7;
/** Maximum number of trial extensions a buyer may take. */
private int maxTrialExtensions = 2;
/**
* Enables the demo-only endpoints (POST /reset, POST /go-live) that reset a team's procurement
* or mark it live without payment. Off by default; turn on ONLY in demo/dev environments —
* /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid.
*/
private boolean demoControlsEnabled = false;
}
@@ -0,0 +1,25 @@
package stirling.software.saas.procurement.license;
import java.time.LocalDateTime;
/**
* Issues and modifies the customer-facing entitlement that actually unlocks the product for an
* enterprise deal — a Keygen licence (trial or annual, connected or air-gapped). This is the seam
* the real Keygen management client plugs into; today {@link MockEnterpriseLicenseService} records
* intent without calling Keygen. Distinct from the EE {@code KeygenLicenseVerifier}, which only
* verifies this instance's own licence.
*/
public interface EnterpriseLicenseService {
/** Issue a time-boxed trial licence for the team; returns the licence reference. */
String issueTrialLicense(Long teamId, LocalDateTime expiresAt);
/** Move a licence's expiry out (trial extension). */
void extendLicense(String licenseRef, LocalDateTime newExpiry);
/** Issue/upgrade to a committed annual licence with the quote's entitlements. */
String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt);
/** Suspend a licence (e.g. payment failed, deal lost). */
void suspendLicense(String licenseRef);
}
@@ -0,0 +1,54 @@
package stirling.software.saas.procurement.license;
import java.time.LocalDateTime;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Mock implementation of {@link EnterpriseLicenseService}: records the intended licence action and
* returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run
* end-to-end while the real Keygen management client is a later drop-in — the seam and the stored
* {@code license_ref} on the deal stay identical.
*/
@Slf4j
@Service
@Profile("saas")
public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
@Override
public String issueTrialLicense(Long teamId, LocalDateTime expiresAt) {
String ref = "mock-trial-" + UUID.randomUUID();
log.info(
"[procurement][mock-license] issue trial team={} expires={} ref={}",
teamId,
expiresAt,
ref);
return ref;
}
@Override
public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
log.info("[procurement][mock-license] extend ref={} newExpiry={}", licenseRef, newExpiry);
}
@Override
public String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt) {
String ref = "mock-annual-" + UUID.randomUUID();
log.info(
"[procurement][mock-license] issue annual team={} deployment={} expires={} ref={}",
teamId,
deployment,
expiresAt,
ref);
return ref;
}
@Override
public void suspendLicense(String licenseRef) {
log.info("[procurement][mock-license] suspend ref={}", licenseRef);
}
}
@@ -0,0 +1,87 @@
package stirling.software.saas.procurement.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A linked team's enterprise commercial journey (one per team). Stage mirrors the buyer journey the
* portal renders (trial -&gt; quote -&gt; agreement -&gt; payment -&gt; live). The entitlement that
* actually unlocks the product is the Keygen licence in {@code licenseRef}; the paid subscription,
* once commercial, is mirrored in {@code billing_subscriptions} and referenced by {@code
* subscriptionId}.
*/
@Entity
@Table(name = "procurement_deal")
@NoArgsConstructor
@Getter
@Setter
public class ProcurementDeal implements Serializable {
private static final long serialVersionUID = 1L;
public static final String STAGE_TRIAL = "trial";
public static final String STAGE_QUOTE = "quote";
public static final String STAGE_AGREEMENT = "security";
public static final String STAGE_PAYMENT = "procurement";
public static final String STAGE_LIVE = "active";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "deal_id")
private Long dealId;
@Column(name = "team_id", nullable = false, unique = true)
private Long teamId;
@Column(name = "stage", nullable = false, length = 32)
private String stage = STAGE_TRIAL;
@Column(name = "trial_started_at")
private LocalDateTime trialStartedAt;
@Column(name = "trial_ends_at")
private LocalDateTime trialEndsAt;
@Column(name = "trial_extensions_used", nullable = false)
private int trialExtensionsUsed;
@Column(name = "license_ref", length = 128)
private String licenseRef;
@Column(name = "subscription_id", length = 255)
private String subscriptionId;
@Column(name = "accepted_quote_id")
private Long acceptedQuoteId;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Version
@Column(name = "version", nullable = false)
private Long version;
public ProcurementDeal(Long teamId) {
this.teamId = teamId;
}
}
@@ -0,0 +1,119 @@
package stirling.software.saas.procurement.model;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A priced, itemised offer built against a {@link ProcurementDeal}. The config columns are the
* buyer's choices; {@code annualNetMinor}/{@code tcvMinor} and {@code lineItemsJson} are the
* server-computed result (never trusted from the client). Stripe fields are populated when the
* accepted quote is turned into a checkout.
*/
@Entity
@Table(name = "procurement_quote")
@NoArgsConstructor
@Getter
@Setter
public class ProcurementQuote implements Serializable {
private static final long serialVersionUID = 1L;
public static final String STATUS_DRAFT = "draft";
public static final String STATUS_SENT = "sent";
public static final String STATUS_ACCEPTED = "accepted";
public static final String STATUS_EXPIRED = "expired";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "quote_id")
private Long quoteId;
@Column(name = "deal_id", nullable = false)
private Long dealId;
@Column(name = "quote_number", nullable = false, length = 64)
private String quoteNumber;
@Column(name = "status", nullable = false, length = 24)
private String status = STATUS_DRAFT;
@Column(name = "currency", nullable = false, length = 8)
private String currency = "USD";
@Column(name = "volume", nullable = false)
private long volume;
@Column(name = "seats")
private Integer seats;
@Column(name = "deployment", length = 24)
private String deployment;
@Column(name = "term_years", nullable = false)
private int termYears;
@Column(name = "service_level", nullable = false, length = 24)
private String serviceLevel;
@Column(name = "indemnification", nullable = false)
private boolean indemnification;
@Column(name = "training", nullable = false)
private boolean training;
@Column(name = "qbr", nullable = false)
private boolean qbr;
@Column(name = "annual_net_minor", nullable = false)
private long annualNetMinor;
@Column(name = "tcv_minor", nullable = false)
private long tcvMinor;
@Column(name = "line_items", columnDefinition = "text")
private String lineItemsJson;
// The Stripe Quote this was issued as (finalized → has a number + PDF). Set by the edge fn.
@Column(name = "stripe_quote_id", length = 128)
private String stripeQuoteId;
// Hosted Stripe invoice URL for the subscription's first invoice, set once the quote is
// accepted.
@Column(name = "stripe_invoice_url", columnDefinition = "text")
private String stripeInvoiceUrl;
// Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it.
@Column(name = "business_name", length = 255)
private String businessName;
@Column(name = "valid_until")
private LocalDate validUntil;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Version
@Column(name = "version", nullable = false)
private Long version;
}
@@ -0,0 +1,53 @@
package stirling.software.saas.procurement.pricing;
/**
* The enterprise rate card: the inputs pricing multiplies against. In production these are read
* from the Stripe price mirror (see {@code StripeMirrorPriceCatalog}); {@link #defaults()} is the
* fallback used when the {@code stripe} schema isn't synced (dev / tests) and is the single source
* of the numbers the marketing prototype encodes.
*
* <p>Per-PDF rates are in minor units (cents) per document. Multipliers are fractions (e.g. 0.15 =
* +15%). Flat/one-time fees are in minor units.
*/
public record PricingRates(
long perPdfMinorUnder1M,
long perPdfMinorUnder5M,
long perPdfMinor5MPlus,
double priorityUplift,
double dedicatedUplift,
double indemnificationUplift,
double[] termDiscountByYear, // index 0 = 1yr … index 4 = 5yr
long qbrAnnualMinor,
long trainingOneTimeMinor) {
public static PricingRates defaults() {
return new PricingRates(
5, // $0.05 / PDF under 1M/yr
4, // $0.04 / PDF at 1M5M/yr
3, // $0.03 / PDF at 5M+/yr
0.15, // priority +15%
0.30, // dedicated +30%
0.05, // IP indemnification +5%
new double[] {0.0, 0.05, 0.10, 0.12, 0.15},
800_000, // QBRs $8,000 / yr
750_000); // onboarding & training $7,500 one-time
}
/** Volume-banded per-PDF rate for an annual volume, in minor units. */
public long perPdfMinor(long annualVolume) {
if (annualVolume >= 5_000_000) return perPdfMinor5MPlus;
if (annualVolume >= 1_000_000) return perPdfMinorUnder5M;
return perPdfMinorUnder1M;
}
public double termDiscount(int termYears) {
int idx = Math.max(1, Math.min(termYears, 5)) - 1;
return termDiscountByYear[idx];
}
public double serviceLevelUplift(String serviceLevel) {
if ("priority".equalsIgnoreCase(serviceLevel)) return priorityUplift;
if ("dedicated".equalsIgnoreCase(serviceLevel)) return dedicatedUplift;
return 0.0;
}
}
@@ -0,0 +1,125 @@
package stirling.software.saas.procurement.pricing;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
/**
* The canonical enterprise pricing engine — the single server-side definition the quote builder,
* the order form, and the Stripe checkout all derive from. A faithful port of the marketing
* prototype's {@code quotePricing}:
*
* <pre>
* annual = volume x perPdfRate x serviceLevelMult x (indemnification ? 1.05 : 1)
* annualNet = round(annual x (1 - termDiscount)) + qbr
* tcv = annualNet x termYears + training
* </pre>
*
* The multi-year discount applies to usage + service level + indemnification, but NOT to the flat
* QBR fee (added after), and one-time training sits outside the recurring total. All money is in
* minor units (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
*/
@Service
public class ProcurementPricingService {
/** Bespoke/committed deals don't price below this ACV; the builder floors against it. */
public static final long MIN_ACV_MINOR = 5_000_000L; // $50,000
/**
* Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working
* days x 1.75), used to prefill the builder's volume step. Rounded, matching the prototype's
* {@code users x 5 x 230 x 1.75}.
*/
public long estimateAnnualVolume(int users) {
return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75);
}
public QuoteBreakdown price(QuoteConfig cfg) {
return price(cfg, PricingRates.defaults());
}
public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) {
// Never trust the client's volume: clamp to non-negative so a tampered request can't drive
// a
// negative amount. The rate card and formula are server-side, so the browser can't lower
// the
// price — only pick a smaller, legitimate config. (See MIN_ACV_MINOR for the committed
// floor,
// a policy decision that is intentionally not force-applied here — see the review notes.)
long volume = Math.max(0, cfg.volume());
long perPdf = rates.perPdfMinor(volume);
long usage = Math.round(volume * (double) perPdf); // base, pre-service-level
double slaUplift = rates.serviceLevelUplift(cfg.serviceLevel());
long withSla = Math.round(usage * (1.0 + slaUplift));
long withIndemnity =
cfg.indemnification()
? Math.round(withSla * (1.0 + rates.indemnificationUplift()))
: withSla;
double termDiscount = rates.termDiscount(cfg.termYears());
long discount = Math.round(withIndemnity * termDiscount);
long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L;
long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L;
long annualNet = (withIndemnity - discount) + qbr;
long tcv = annualNet * cfg.termYears() + training;
List<QuoteLineItem> lines = new ArrayList<>();
lines.add(
new QuoteLineItem("usage", "PDF processing", QuoteLineItem.Kind.RECURRING, usage));
lines.add(
new QuoteLineItem(
"seats",
"Unlimited users + SSO / SCIM / RBAC",
QuoteLineItem.Kind.INCLUDED,
0L));
if (withSla != usage) {
lines.add(
new QuoteLineItem(
"service-level",
serviceLevelLabel(cfg.serviceLevel()),
QuoteLineItem.Kind.RECURRING,
withSla - usage));
}
if (withIndemnity != withSla) {
lines.add(
new QuoteLineItem(
"indemnification",
"IP indemnification",
QuoteLineItem.Kind.RECURRING,
withIndemnity - withSla));
}
if (qbr > 0) {
lines.add(
new QuoteLineItem(
"qbr",
"Quarterly business reviews",
QuoteLineItem.Kind.RECURRING,
qbr));
}
if (discount > 0) {
lines.add(
new QuoteLineItem(
"multi-year",
cfg.termYears() + "-year commitment",
QuoteLineItem.Kind.DISCOUNT,
-discount));
}
if (training > 0) {
lines.add(
new QuoteLineItem(
"training",
"Onboarding & training",
QuoteLineItem.Kind.ONE_TIME,
training));
}
return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
}
private static String serviceLevelLabel(String serviceLevel) {
if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority service level";
if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated service level";
return "Standard service level";
}
}
@@ -0,0 +1,12 @@
package stirling.software.saas.procurement.pricing;
import java.util.List;
/**
* The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the
* order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee
* after the multi-year discount; {@code tcvMinor} is total contract value across the term including
* one-time fees. Minor units (cents).
*/
public record QuoteBreakdown(
List<QuoteLineItem> lineItems, long annualNetMinor, long tcvMinor, String currency) {}
@@ -0,0 +1,26 @@
package stirling.software.saas.procurement.pricing;
/**
* The buyer-configurable inputs to an enterprise quote. Mirrors the quote builder's four steps
* (volume, commitment &amp; service, add-ons) and is the sole input to {@link
* ProcurementPricingService}. Amounts are never carried here — the service derives them from these
* choices and the rate card.
*/
public record QuoteConfig(
long volume, // committed PDFs per year
int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
String deployment, // cloud | selfhost | airgap (inherited from the trial; not priced)
int termYears, // 1..5
String serviceLevel, // standard | priority | dedicated
boolean indemnification,
boolean training,
boolean qbr,
String currency) { // USD | EUR | GBP
public QuoteConfig {
if (termYears < 1) termYears = 1;
if (termYears > 5) termYears = 5;
if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
if (currency == null || currency.isBlank()) currency = "USD";
}
}
@@ -0,0 +1,16 @@
package stirling.software.saas.procurement.pricing;
/**
* One line on the itemised quote. {@code amountMinor} is in the currency's minor unit (cents);
* discounts are negative. {@code kind} drives how the portal groups it (recurring annual vs a
* one-time fee vs a discount line).
*/
public record QuoteLineItem(String key, String label, Kind kind, long amountMinor) {
public enum Kind {
RECURRING,
ONE_TIME,
DISCOUNT,
INCLUDED
}
}
@@ -0,0 +1,17 @@
package stirling.software.saas.procurement.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import stirling.software.saas.procurement.model.ProcurementDeal;
public interface ProcurementDealRepository extends JpaRepository<ProcurementDeal, Long> {
Optional<ProcurementDeal> findByTeamId(Long teamId);
boolean existsByTeamId(Long teamId);
/** Reset: drop the team's deal (quotes + activity cascade via FK). */
void deleteByTeamId(Long teamId);
}
@@ -0,0 +1,12 @@
package stirling.software.saas.procurement.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import stirling.software.saas.procurement.model.ProcurementQuote;
public interface ProcurementQuoteRepository extends JpaRepository<ProcurementQuote, Long> {
List<ProcurementQuote> findByDealIdOrderByCreatedAtDesc(Long dealId);
}
@@ -0,0 +1,244 @@
package stirling.software.saas.procurement.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
/**
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
* server-priced quote, and accept it. Stripe checkout itself lives in a Supabase edge function the
* portal calls with the accepted quote; on payment the webhook seeds {@code billing_subscriptions}
* and this service issues the annual licence. All amounts are minor units (cents).
*/
@Slf4j
@Service
@Profile("saas")
public class ProcurementService {
// Local mapper for the line-items JSON snapshot; the saas context exposes no injectable
// ObjectMapper bean, and this (de)serialisation doesn't need Spring's configured one.
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final ProcurementDealRepository dealRepo;
private final ProcurementQuoteRepository quoteRepo;
private final ProcurementPricingService pricing;
private final EnterpriseLicenseService licenses;
private final ProcurementConfigurationProperties config;
public ProcurementService(
ProcurementDealRepository dealRepo,
ProcurementQuoteRepository quoteRepo,
ProcurementPricingService pricing,
EnterpriseLicenseService licenses,
ProcurementConfigurationProperties config) {
this.dealRepo = dealRepo;
this.quoteRepo = quoteRepo;
this.pricing = pricing;
this.licenses = licenses;
this.config = config;
}
@Transactional(readOnly = true)
public Optional<ProcurementDeal> getDeal(Long teamId) {
return dealRepo.findByTeamId(teamId);
}
@Transactional(readOnly = true)
public List<ProcurementQuote> quotesForDeal(Long dealId) {
return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId);
}
/**
* Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
* window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
* Keygen licence, and the deal row is the journey state.
*/
@Transactional
public ProcurementDeal startTrial(Long teamId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
LocalDateTime now = LocalDateTime.now();
LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
deal.setStage(ProcurementDeal.STAGE_TRIAL);
deal.setTrialStartedAt(now);
deal.setTrialEndsAt(ends);
deal.setTrialExtensionsUsed(0);
deal.setLicenseRef(licenses.issueTrialLicense(teamId, ends));
deal = dealRepo.save(deal);
log.info(
"[procurement] trial started team={} deal={} ends={}",
teamId,
deal.getDealId(),
ends);
return deal;
}
/** Extend the current trial by the configured increment, up to the cap. */
@Transactional
public ProcurementDeal extendTrial(Long teamId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
// Only extend while still in the trial. Past the trial (e.g. active), licenseRef points at
// the committed annual licence — extending would rewind its expiry.
if (!ProcurementDeal.STAGE_TRIAL.equals(deal.getStage())) {
throw new IllegalStateException("Trial extension only allowed during the trial stage");
}
if (deal.getTrialExtensionsUsed() >= config.getMaxTrialExtensions()) {
throw new IllegalStateException("Trial extension cap reached");
}
LocalDateTime base =
deal.getTrialEndsAt() != null ? deal.getTrialEndsAt() : LocalDateTime.now();
LocalDateTime newEnd = base.plusDays(config.getTrialExtensionDays());
deal.setTrialEndsAt(newEnd);
deal.setTrialExtensionsUsed(deal.getTrialExtensionsUsed() + 1);
if (deal.getLicenseRef() != null) {
licenses.extendLicense(deal.getLicenseRef(), newEnd);
}
return dealRepo.save(deal);
}
/** Price a quote config server-side and persist it as a draft against the team's deal. */
@Transactional
public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) {
throw new IllegalStateException("Cannot rebuild a quote on a live deal");
}
// (Re)building a quote returns the deal to the quote stage and drops any prior acceptance,
// so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer.
deal.setStage(ProcurementDeal.STAGE_QUOTE);
deal.setAcceptedQuoteId(null);
deal = dealRepo.save(deal);
QuoteBreakdown breakdown = pricing.price(cfg);
ProcurementQuote quote = new ProcurementQuote();
quote.setDealId(deal.getDealId());
quote.setQuoteNumber(nextQuoteNumber(deal.getDealId()));
// Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT.
quote.setStatus(ProcurementQuote.STATUS_DRAFT);
quote.setCurrency(cfg.currency());
quote.setVolume(cfg.volume());
quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
quote.setDeployment(cfg.deployment());
quote.setTermYears(cfg.termYears());
quote.setServiceLevel(cfg.serviceLevel());
quote.setIndemnification(cfg.indemnification());
quote.setTraining(cfg.training());
quote.setQbr(cfg.qbr());
quote.setBusinessName(businessName);
quote.setAnnualNetMinor(breakdown.annualNetMinor());
quote.setTcvMinor(breakdown.tcvMinor());
quote.setLineItemsJson(writeLineItems(breakdown));
quote.setValidUntil(LocalDate.now().plusDays(30));
quote = quoteRepo.save(quote);
log.info(
"[procurement] quote built team={} quote={} annualNet={} tcv={}",
teamId,
quote.getQuoteNumber(),
quote.getAnnualNetMinor(),
quote.getTcvMinor());
return quote;
}
/**
* Advance the deal to the agreement (security) stage: the buyer has an issued quote and is
* reviewing the enterprise agreement before it's accepted into a subscription. Requires an
* issued quote on the deal.
*/
@Transactional
public ProcurementDeal startAgreement(Long teamId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
boolean hasIssuedQuote =
quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
.anyMatch(q -> ProcurementQuote.STATUS_SENT.equals(q.getStatus()));
if (!hasIssuedQuote) {
throw new IllegalStateException("No issued quote for team " + teamId);
}
deal.setStage(ProcurementDeal.STAGE_AGREEMENT);
deal = dealRepo.save(deal);
log.info("[procurement] agreement stage team={} deal={}", teamId, deal.getDealId());
return deal;
}
/**
* Mark the deal live: issue the annual licence and advance to the active stage. In production
* this is driven by the {@code invoice.paid} webhook once the first invoice is settled; this
* method is the demo/manual stand-in until that webhook lands.
*/
@Transactional
public ProcurementDeal markLive(Long teamId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
int term = 1;
String deployment = "cloud";
if (deal.getAcceptedQuoteId() != null) {
ProcurementQuote q = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
if (q != null) {
term = Math.max(1, q.getTermYears());
if (q.getDeployment() != null && !q.getDeployment().isBlank()) {
deployment = q.getDeployment();
}
}
}
deal.setLicenseRef(
licenses.issueAnnualLicense(
teamId, deployment, LocalDateTime.now().plusYears(term)));
deal.setStage(ProcurementDeal.STAGE_LIVE);
deal = dealRepo.save(deal);
log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
return deal;
}
/**
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
* re-demos/testing.
*/
@Transactional
public void resetDeal(Long teamId) {
dealRepo.deleteByTeamId(teamId);
log.info("[procurement] deal reset team={}", teamId);
}
private String nextQuoteNumber(Long dealId) {
int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1;
String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT);
return String.format(Locale.ROOT, "QT-%s-%04d", token, seq);
}
private String writeLineItems(QuoteBreakdown breakdown) {
try {
return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
} catch (JsonProcessingException e) {
log.warn("[procurement] failed to serialise line items", e);
return "[]";
}
}
}
@@ -0,0 +1,72 @@
-- Enterprise procurement: the tables that track a linked team's journey from trial to live.
--
-- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the
-- quotes built against it (the itemised, priced offers), and an append-only activity log for the
-- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions
-- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence
-- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService).
--
-- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch.
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal (
deal_id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
-- one active deal per team; the journey lives on this row.
stage VARCHAR(32) NOT NULL DEFAULT 'trial',
-- trial | quote | security (agreement) | procurement (payment) | active (live)
trial_started_at TIMESTAMP,
trial_ends_at TIMESTAMP,
trial_extensions_used INT NOT NULL DEFAULT 0,
license_ref VARCHAR(128),
-- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands.
subscription_id VARCHAR(255),
-- Stripe subscription id, mirrored into billing_subscriptions once commercial.
accepted_quote_id BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
version BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote (
quote_id BIGSERIAL PRIMARY KEY,
deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
quote_number VARCHAR(64) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'draft',
-- draft | sent | accepted | expired
currency VARCHAR(8) NOT NULL DEFAULT 'USD',
volume BIGINT NOT NULL,
seats INT,
deployment VARCHAR(24),
term_years INT NOT NULL,
service_level VARCHAR(24) NOT NULL,
indemnification BOOLEAN NOT NULL DEFAULT FALSE,
training BOOLEAN NOT NULL DEFAULT FALSE,
qbr BOOLEAN NOT NULL DEFAULT FALSE,
annual_net_minor BIGINT NOT NULL,
-- recurring annual fee after the multi-year discount, in minor units (cents).
tcv_minor BIGINT NOT NULL,
-- total contract value across the term incl. one-time fees, minor units.
line_items TEXT,
-- JSON snapshot of the itemised lines the order form renders.
stripe_price_id VARCHAR(128),
checkout_session_id VARCHAR(255),
checkout_url TEXT,
valid_until DATE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
version BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity (
activity_id BIGSERIAL PRIMARY KEY,
deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
actor_user_id BIGINT,
-- the internal/portal user who took the action; informational (no FK).
action VARCHAR(48) NOT NULL,
-- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ...
detail TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id);
CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id);
@@ -0,0 +1,8 @@
-- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF +
-- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The
-- Stripe operations live in Supabase edge functions; these columns hold the references they write
-- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128),
ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT;
@@ -0,0 +1,5 @@
-- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on
-- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS business_name VARCHAR(255);
@@ -0,0 +1,87 @@
package stirling.software.saas.procurement.pricing;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import stirling.software.saas.procurement.pricing.QuoteLineItem.Kind;
/**
* Locks the pricing engine to the numbers the marketing prototype encodes — most importantly the
* canonical quote QT-AC9F-0001 (1M PDFs, priority, 3-year) = $41,400/yr, $124,200 TCV.
*/
class ProcurementPricingServiceTest {
private final ProcurementPricingService pricing = new ProcurementPricingService();
private static QuoteConfig cfg(long volume, String sla, int term) {
return new QuoteConfig(volume, 0, "cloud", term, sla, false, false, false, "USD");
}
@Test
void canonicalQuoteMatchesPrototype() {
QuoteBreakdown q = pricing.price(cfg(1_000_000, "priority", 3));
assertThat(q.annualNetMinor()).isEqualTo(4_140_000L); // $41,400
assertThat(q.tcvMinor()).isEqualTo(12_420_000L); // $124,200
assertThat(lineAmount(q, "usage")).isEqualTo(4_000_000L); // $40,000 @ $0.04
assertThat(lineAmount(q, "service-level")).isEqualTo(600_000L); // +15%
assertThat(lineAmount(q, "multi-year")).isEqualTo(-460_000L); // -10%
}
@Test
void volumeBandsPickTheRightPerPdfRate() {
assertThat(lineAmount(pricing.price(cfg(500_000, "standard", 1)), "usage"))
.isEqualTo(2_500_000L); // 500k @ $0.05
assertThat(lineAmount(pricing.price(cfg(1_000_000, "standard", 1)), "usage"))
.isEqualTo(4_000_000L); // 1M @ $0.04
assertThat(lineAmount(pricing.price(cfg(5_000_000, "standard", 1)), "usage"))
.isEqualTo(15_000_000L); // 5M @ $0.03
}
@Test
void addOnsAndTermStack() {
QuoteConfig c =
new QuoteConfig(1_000_000, 0, "cloud", 5, "dedicated", true, true, true, "USD");
QuoteBreakdown q = pricing.price(c);
long usage = 4_000_000L;
long withSla = Math.round(usage * 1.30); // 5,200,000
long withIndemnity = Math.round(withSla * 1.05); // 5,460,000
long discount = Math.round(withIndemnity * 0.15); // 819,000
long qbr = 800_000L;
long expectedAnnual = (withIndemnity - discount) + qbr;
long expectedTcv = expectedAnnual * 5 + 750_000L; // + training one-time
assertThat(q.annualNetMinor()).isEqualTo(expectedAnnual);
assertThat(q.tcvMinor()).isEqualTo(expectedTcv);
assertThat(q.lineItems())
.anyMatch(l -> l.key().equals("training") && l.kind() == Kind.ONE_TIME);
assertThat(q.lineItems()).anyMatch(l -> l.key().equals("qbr"));
assertThat(q.lineItems()).anyMatch(l -> l.key().equals("indemnification"));
}
@Test
void standardSingleYearHasNoUpliftOrDiscountLines() {
QuoteBreakdown q = pricing.price(cfg(1_000_000, "standard", 1));
assertThat(q.annualNetMinor()).isEqualTo(4_000_000L);
assertThat(q.tcvMinor()).isEqualTo(4_000_000L);
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("service-level"));
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("multi-year"));
}
@Test
void volumeEstimateFromSeats() {
// ~2,013 PDFs/user/yr
assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L);
assertThat(pricing.estimateAnnualVolume(0)).isZero();
}
private static long lineAmount(QuoteBreakdown q, String key) {
return q.lineItems().stream()
.filter(l -> l.key().equals(key))
.mapToLong(QuoteLineItem::amountMinor)
.findFirst()
.orElseThrow();
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.14.0'
version = '2.14.1'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
+32 -4
View File
@@ -59,6 +59,36 @@ class ToolDiscovery:
"/api/v1/convert/",
)
# Endpoints under the allowed prefixes that are NOT edit-agent operations. A listed
# path and everything nested under it is dropped. Several kinds live here:
EXCLUDED_PATHS = (
# 1. Cert-signing family: needs certificate/key files the agent can't supply, plus
# interactive session and hardware-token management. The whole subtree is dropped.
"/api/v1/security/cert-sign",
# 2. Interactive PDF text-editor endpoints, not one-shot operations.
"/api/v1/convert/pdf/text-editor",
"/api/v1/convert/text-editor/pdf",
# 3. Introspection / query endpoints that return metadata, a listing, or a
# verification verdict rather than a transformed document, so they belong to
# the question path, not the edit agent. (decompress is a dev-only stream op.)
"/api/v1/security/get-info-on-pdf",
"/api/v1/security/verify-pdf",
"/api/v1/security/validate-signature",
"/api/v1/misc/list-attachments",
"/api/v1/misc/show-javascript",
"/api/v1/misc/decompress-pdf",
"/api/v1/general/extract-bookmarks",
# 4. Require a secondary file (image, overlay PDF, attachments) on top of the input
# PDF. The agent only ever supplies the input PDF(s), so these can never run.
# (add-stamp / add-watermark stay: their text mode needs no extra file.)
"/api/v1/misc/add-image",
"/api/v1/misc/add-attachments",
"/api/v1/general/overlay-pdfs",
)
def _is_excluded(self, path: str) -> bool:
return any(path == p or path.startswith(p + "/") for p in self.EXCLUDED_PATHS)
def __init__(self, spec: dict[str, Any]):
resource = Resource.from_contents(spec, default_specification=DRAFT202012)
self.resolver = Registry().with_resource("", resource).resolver()
@@ -73,17 +103,15 @@ class ToolDiscovery:
for path, path_item in sorted(self.spec.get("paths", {}).items()):
if "{" in path or not any(path.startswith(p) for p in self.ALLOWED_PATH_PREFIXES):
continue
if self._is_excluded(path):
continue
body_schema = self._get_request_body_schema(path_item) or {}
query_props = self._get_query_parameters(path_item)
body_props = body_schema.get("properties") or {}
# Body properties win on name collision — body is the canonical param source
# for the existing tools; query params are additive.
properties = {**query_props, **body_props}
if not properties:
continue
clean_props = self._filter_properties(properties)
if not clean_props:
continue
enum_name = _deduplicate(_path_to_enum_name(path), used_enum)
class_name = _deduplicate(_path_to_class_name(path), used_class)
+2 -19
View File
@@ -15,7 +15,6 @@ from stirling.agents.pdf_review import PdfReviewAgent
from stirling.agents.user_spec import UserSpecAgent
from stirling.contracts import (
AgentDraftWorkflowResponse,
ConvertMarkdownResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
@@ -48,7 +47,7 @@ class OrchestratorAgent:
ToolOutput(
self.delegate_pdf_edit,
name="delegate_pdf_edit",
description="Delegate requests for PDF modifications and return the PDF edit result.",
description="Delegate requests to modify or convert PDFs and return the PDF edit result.",
),
ToolOutput(
self.delegate_pdf_question,
@@ -71,13 +70,6 @@ class OrchestratorAgent:
" feedback')."
),
),
ToolOutput(
self.delegate_pdf_ingest,
name="delegate_pdf_ingest",
description=(
"Delegate requests to convert a PDF to Markdown or extract its content as readable text."
),
),
ToolOutput(
self.delegate_pdf_create,
name="delegate_pdf_create",
@@ -98,7 +90,7 @@ class OrchestratorAgent:
system_prompt=(
"You are the top-level orchestrator. "
"Choose exactly one output function that best handles the request. "
"Use delegate_pdf_edit for any requested modification of one or more PDFs. "
"Use delegate_pdf_edit for any request to modify or convert one or more PDFs. "
"Use delegate_pdf_question for questions about the contents of the attached PDFs. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use delegate_pdf_review when the user wants the PDF returned with review"
@@ -106,8 +98,6 @@ class OrchestratorAgent:
" 'leave feedback on the PDF'. "
"Use delegate_pdf_create when the user wants to generate a new document from"
" scratch with no input file — invoices, reports, letters, contracts, etc. "
"Use delegate_pdf_ingest for any request to convert a PDF to Markdown "
"or extract its content as readable text. "
"Use unsupported_capability when the user asks about the assistant itself "
"or when none of the other outputs fit; supply a helpful message."
),
@@ -177,13 +167,6 @@ class OrchestratorAgent:
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).orchestrate(request)
async def delegate_pdf_ingest(self, ctx: RunContext[OrchestratorDeps]) -> ConvertMarkdownResponse:
request = ctx.deps.request
return ConvertMarkdownResponse(
reason="PDF to Markdown requested — Java converts deterministically.",
files_to_ingest=request.files,
)
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> PdfReviewOrchestrateResponse:
return await self._run_pdf_review(ctx.deps.request)
@@ -13,7 +13,6 @@ from .common import (
AiFile,
ArtifactKind,
ConversationMessage,
ConvertMarkdownResponse,
ExtractedFileText,
GenerateFileResponse,
MathAuditorToolReportArtifact,
@@ -163,7 +162,6 @@ __all__ = [
"NeedContentFileRequest",
"NeedContentResponse",
"NeedIngestResponse",
"ConvertMarkdownResponse",
"NextExecutionAction",
"OrchestratorRequest",
"OrchestratorResponse",
-14
View File
@@ -62,7 +62,6 @@ class WorkflowOutcome(StrEnum):
CANNOT_CONTINUE = "cannot_continue"
UNSUPPORTED_CAPABILITY = "unsupported_capability"
GENERATE_FILE = "generate_file"
CONVERT_MARKDOWN = "convert_markdown"
class ArtifactKind(StrEnum):
@@ -184,19 +183,6 @@ class NeedIngestResponse(ApiModel):
content_types: list[PdfContentType] = Field(default_factory=list)
class ConvertMarkdownResponse(ApiModel):
"""Terminal signal: convert the listed files to Markdown deterministically.
This is a deterministic, non-AI conversion. Java runs the PDF→Markdown converter
(``PdfMarkdownConverter``) on each file and returns the resulting ``.md`` file(s) as a
completed result. There is no resume turn — the conversion output is the final answer.
"""
outcome: Literal[WorkflowOutcome.CONVERT_MARKDOWN] = WorkflowOutcome.CONVERT_MARKDOWN
reason: str
files_to_ingest: list[AiFile]
class ToolOperationStep(ApiModel):
kind: Literal[StepKind.TOOL] = StepKind.TOOL
tool: AnyToolId
@@ -11,7 +11,6 @@ from .common import (
AiFile,
ArtifactKind,
ConversationMessage,
ConvertMarkdownResponse,
ExtractedFileText,
GenerateFileResponse,
NeedContentResponse,
@@ -61,7 +60,6 @@ type OrchestratorResponse = Annotated[
| GenerateFileResponse
| NeedContentResponse
| NeedIngestResponse
| ConvertMarkdownResponse
| AgentDraftResponse
| NextExecutionAction
| UnsupportedCapabilityResponse,
+111 -168
View File
@@ -11,13 +11,6 @@ from pydantic import Field, RootModel, SecretStr
from stirling.models.base import ApiModel
class AddAttachmentsParams(ApiModel):
attachments: list[bytes] = Field(..., description="The image file to be overlaid onto the PDF.")
convert_to_pdf_a3b: bool = Field(
False, description="Convert the resulting PDF to PDF/A-3b format after adding attachments"
)
class AddCommentsParams(ApiModel):
comments: str = Field(
...,
@@ -28,12 +21,6 @@ class AddCommentsParams(ApiModel):
)
class AddImageParams(ApiModel):
every_page: bool = Field(False, description="Whether to overlay the image onto every page of the PDF.")
x: float = Field(0, description="The x-coordinate at which to place the top-left corner of the image.")
y: float = Field(0, description="The y-coordinate at which to place the top-left corner of the image.")
class CustomMargin(StrEnum):
"""
Custom margin: small/medium/large/x-large
@@ -107,7 +94,7 @@ class KeyLength(IntEnum):
class AddPasswordParams(ApiModel):
key_length: KeyLength = Field(..., description="The length of the encryption key")
key_length: KeyLength = Field(KeyLength.integer_256, description="The length of the encryption key")
owner_password: SecretStr | None = Field(
None,
description="The owner password to be added to the PDF file (Restricts what can be done with the document once it is opened)",
@@ -312,50 +299,6 @@ class CbzToPdfParams(ApiModel):
optimize_for_ebook: bool = Field(False, description="Optimize the output PDF for ebook reading using Ghostscript")
class CertType(StrEnum):
"""
The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.
"""
pem = "PEM"
pkcs12 = "PKCS12"
pfx = "PFX"
jks = "JKS"
server = "SERVER"
windows_store = "WINDOWS_STORE"
pkcs11 = "PKCS11"
class CertSignParams(ApiModel):
alias: str | None = Field(
None,
description="The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates.",
)
cert_type: CertType = Field(
...,
description="The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.",
)
location: str = Field("SPDF", description="The location where the PDF is signed")
name: str = Field("SPDF", description="The name of the signer")
page_number: int = Field(
1,
description="The page number where the signature should be visible. This is required if showSignature is set to true",
)
password: SecretStr | None = Field(
None, description="The password for the keystore / private key, or the token PIN for PKCS11"
)
pkcs11_library_path: str | None = Field(
None,
description="Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must be an allowed driver - a detected one or configured via STIRLING_PKCS11_LIBRARIES.",
)
pkcs11_slot: int | None = Field(
None, description="Optional PKCS#11 slot index. When omitted the first slot with a token is used."
)
reason: str = Field("Signed by SPDF", description="The reason for signing the PDF")
show_logo: bool = Field(True, description="Whether to visually show a signature logo along with the signature")
show_signature: bool = Field(False, description="Whether to visually show the signature in the PDF file")
class LineArtEdgeLevel(IntEnum):
"""
Edge detection strength to use for line art conversion (1-3). This maps to ImageMagick's -edge radius.
@@ -525,6 +468,10 @@ class EmlToPdfParams(ApiModel):
)
class ExtractAttachmentsParams(ApiModel):
pass
class ExtractImageScansParams(ApiModel):
angle_threshold: int = Field(5, description="The angle threshold for the image scan extraction")
border_size: int = Field(1, description="The border size for the image scan extraction")
@@ -547,6 +494,10 @@ class ExtractImagesParams(ApiModel):
format: Format = Field(Format.png, description="The output image format e.g., 'png', 'jpeg', or 'gif'")
class FileToPdfParams(ApiModel):
pass
class FlattenParams(ApiModel):
flatten_only_forms: bool = Field(
False, description="True to flatten only the forms, false to flatten full PDF (Convert page to image)"
@@ -602,6 +553,10 @@ class ImgToPdfParams(ApiModel):
)
class MarkdownToPdfParams(ApiModel):
pass
class SortType(StrEnum):
"""
The type of sorting to be applied on the input files before merging.
@@ -750,41 +705,6 @@ class OcrPdfParams(ApiModel):
sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true")
class OverlayMode(StrEnum):
"""
The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts
"""
sequential_overlay = "SequentialOverlay"
interleaved_overlay = "InterleavedOverlay"
fixed_repeat_overlay = "FixedRepeatOverlay"
class OverlayPosition(Enum):
"""
Overlay position 0 is Foregound, 1 is Background
"""
number_0 = 0
number_1 = 1
class OverlayPdfsParams(ApiModel):
counts: list[int] | None = Field(
None,
description="An array of integers specifying the number of times each corresponding overlay file should be applied in the 'FixedRepeatOverlay' mode. This should match the length of the overlayFiles array.",
)
overlay_files: list[bytes] = Field(
...,
description="An array of PDF files to be used as overlays on the base PDF. The order in these files is applied based on the selected mode.",
)
overlay_mode: OverlayMode = Field(
...,
description="The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts",
)
overlay_position: OverlayPosition = Field(..., description="Overlay position 0 is Foregound, 1 is Background")
class PdfToCbrParams(ApiModel):
dpi: int = Field(..., description="The DPI (Dots Per Inch) for rendering PDF pages as images", examples=[150])
@@ -841,6 +761,12 @@ class PdfToEpubParams(ApiModel):
)
class PdfToHtmlParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
class ImageFormat(StrEnum):
"""
The output image format
@@ -877,6 +803,12 @@ class PdfToImgParams(ApiModel):
)
class PdfToMarkdownParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
class OutputFormat1(StrEnum):
"""
The output format type (PDF/A or PDF/X)
@@ -912,13 +844,11 @@ class PdfToPresentationParams(ApiModel):
output_format: OutputFormat2 = Field(..., description="The output Presentation format")
class PdfToTextEditorParams(ApiModel):
class PdfToSinglePageParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
lightweight: bool = False
class OutputFormat3(StrEnum):
"""
@@ -979,10 +909,10 @@ class PdfToXlsxParams(ApiModel):
)
class Pkcs11CertificatesParams(ApiModel):
library_path: str | None = None
pin: str | None = None
slot: int | None = None
class PdfToXmlParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
class CustomMode(StrEnum):
@@ -1061,6 +991,18 @@ class RemoveBlanksParams(ApiModel):
)
class RemoveCertSignParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
class RemoveImagePdfParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
class RemovePagesParams(ApiModel):
page_numbers: str = Field(
"all",
@@ -1077,6 +1019,12 @@ class RenameAttachmentParams(ApiModel):
new_name: str = Field(..., description="The new name for the attachment")
class RepairParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
class HighContrastColorCombination(StrEnum):
"""
If HIGH_CONTRAST_COLOR option selected, then pick the default color option for text and background.
@@ -1237,27 +1185,6 @@ class ScannerEffectParams(ApiModel):
yellowish: bool | None = Field(None, description="Simulate yellowed paper", examples=[False])
class WorkflowType(StrEnum):
signing = "SIGNING"
review = "REVIEW"
approval = "APPROVAL"
class Request(ApiModel):
document_name: str | None = None
due_date: str | None = None
message: str | None = None
owner_email: str | None = None
participant_emails: list[str] | None = None
participant_user_ids: list[int] | None = None
workflow_metadata: str | None = None
workflow_type: WorkflowType | None = None
class SessionsParams(ApiModel):
request: Request | None = None
class SplitBySizeOrCountParams(ApiModel):
split_type: int = Field(
0, description="Determines the type of split: 0 for size, 1 for page count, 2 for document count"
@@ -1283,8 +1210,8 @@ class PageSize1(StrEnum):
class SplitForPosterPrintParams(ApiModel):
page_size: PageSize1 = Field(..., description="Target page size for output chunks (e.g., 'A4', 'Letter', 'A3')")
right_to_left: bool = Field(False, description="Split right-to-left instead of left-to-right")
xfactor: int | None = None
yfactor: int | None = None
x_factor: int = Field(2, description="Horizontal decimation factor (how many columns to split into)", ge=1, le=10)
y_factor: int = Field(2, description="Vertical decimation factor (how many rows to split into)", ge=1, le=10)
class SplitPagesParams(ApiModel):
@@ -1360,6 +1287,12 @@ class TimestampPdfParams(ApiModel):
)
class UnlockPdfFormsParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
class Trapped(StrEnum):
"""
The trapped status of the document
@@ -1399,11 +1332,6 @@ class UrlToPdfParams(ApiModel):
url_input: str = Field(..., description="The input URL to be converted to a PDF file")
class ValidateCertificateParams(ApiModel):
cert_type: str | None = None
password: str | None = None
class OutputFormat6(StrEnum):
"""
Target vector format extension
@@ -1462,20 +1390,24 @@ class Model(
| CbzToPdfParams
| EbookToPdfParams
| EmlToPdfParams
| FileToPdfParams
| HtmlToPdfParams
| ImgToPdfParams
| MarkdownToPdfParams
| PdfToCbrParams
| PdfToCbzParams
| PdfToCsvParams
| PdfToEpubParams
| PdfToHtmlParams
| PdfToImgParams
| PdfToMarkdownParams
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
| PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
| PdfToXmlParams
| SvgToPdfParams
| UrlToPdfParams
| VectorToPdfParams
@@ -1485,8 +1417,9 @@ class Model(
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
| OverlayPdfsParams
| PdfToSinglePageParams
| RearrangePagesParams
| RemoveImagePdfParams
| RemovePagesParams
| RotatePdfParams
| ScalePagesParams
@@ -1495,33 +1428,31 @@ class Model(
| SplitPagesParams
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
| AddAttachmentsParams
| AddCommentsParams
| AddImageParams
| AddPageNumbersParams
| AddStampParams
| AutoRenameParams
| AutoSplitPdfParams
| CompressPdfParams
| DeleteAttachmentParams
| ExtractAttachmentsParams
| ExtractImageScansParams
| ExtractImagesParams
| FlattenParams
| OcrPdfParams
| RemoveBlanksParams
| RenameAttachmentParams
| RepairParams
| ReplaceInvertPdfParams
| ScannerEffectParams
| UnlockPdfFormsParams
| UpdateMetadataParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
| CertSignParams
| Pkcs11CertificatesParams
| SessionsParams
| ValidateCertificateParams
| RedactParams
| RedactExecuteParams
| RemoveCertSignParams
| RemovePasswordParams
| SanitizePdfParams
| TimestampPdfParams
@@ -1532,20 +1463,24 @@ class Model(
| CbzToPdfParams
| EbookToPdfParams
| EmlToPdfParams
| FileToPdfParams
| HtmlToPdfParams
| ImgToPdfParams
| MarkdownToPdfParams
| PdfToCbrParams
| PdfToCbzParams
| PdfToCsvParams
| PdfToEpubParams
| PdfToHtmlParams
| PdfToImgParams
| PdfToMarkdownParams
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
| PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
| PdfToXmlParams
| SvgToPdfParams
| UrlToPdfParams
| VectorToPdfParams
@@ -1555,8 +1490,9 @@ class Model(
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
| OverlayPdfsParams
| PdfToSinglePageParams
| RearrangePagesParams
| RemoveImagePdfParams
| RemovePagesParams
| RotatePdfParams
| ScalePagesParams
@@ -1565,33 +1501,31 @@ class Model(
| SplitPagesParams
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
| AddAttachmentsParams
| AddCommentsParams
| AddImageParams
| AddPageNumbersParams
| AddStampParams
| AutoRenameParams
| AutoSplitPdfParams
| CompressPdfParams
| DeleteAttachmentParams
| ExtractAttachmentsParams
| ExtractImageScansParams
| ExtractImagesParams
| FlattenParams
| OcrPdfParams
| RemoveBlanksParams
| RenameAttachmentParams
| RepairParams
| ReplaceInvertPdfParams
| ScannerEffectParams
| UnlockPdfFormsParams
| UpdateMetadataParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
| CertSignParams
| Pkcs11CertificatesParams
| SessionsParams
| ValidateCertificateParams
| RedactParams
| RedactExecuteParams
| RemoveCertSignParams
| RemovePasswordParams
| SanitizePdfParams
| TimestampPdfParams
@@ -1603,20 +1537,24 @@ type ParamToolModel = (
| CbzToPdfParams
| EbookToPdfParams
| EmlToPdfParams
| FileToPdfParams
| HtmlToPdfParams
| ImgToPdfParams
| MarkdownToPdfParams
| PdfToCbrParams
| PdfToCbzParams
| PdfToCsvParams
| PdfToEpubParams
| PdfToHtmlParams
| PdfToImgParams
| PdfToMarkdownParams
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
| PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
| PdfToXmlParams
| SvgToPdfParams
| UrlToPdfParams
| VectorToPdfParams
@@ -1626,8 +1564,9 @@ type ParamToolModel = (
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
| OverlayPdfsParams
| PdfToSinglePageParams
| RearrangePagesParams
| RemoveImagePdfParams
| RemovePagesParams
| RotatePdfParams
| ScalePagesParams
@@ -1636,33 +1575,31 @@ type ParamToolModel = (
| SplitPagesParams
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
| AddAttachmentsParams
| AddCommentsParams
| AddImageParams
| AddPageNumbersParams
| AddStampParams
| AutoRenameParams
| AutoSplitPdfParams
| CompressPdfParams
| DeleteAttachmentParams
| ExtractAttachmentsParams
| ExtractImageScansParams
| ExtractImagesParams
| FlattenParams
| OcrPdfParams
| RemoveBlanksParams
| RenameAttachmentParams
| RepairParams
| ReplaceInvertPdfParams
| ScannerEffectParams
| UnlockPdfFormsParams
| UpdateMetadataParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
| CertSignParams
| Pkcs11CertificatesParams
| SessionsParams
| ValidateCertificateParams
| RedactParams
| RedactExecuteParams
| RemoveCertSignParams
| RemovePasswordParams
| SanitizePdfParams
| TimestampPdfParams
@@ -1675,20 +1612,24 @@ class ToolEndpoint(StrEnum):
CBZ_TO_PDF = "/api/v1/convert/cbz/pdf"
EBOOK_TO_PDF = "/api/v1/convert/ebook/pdf"
EML_TO_PDF = "/api/v1/convert/eml/pdf"
FILE_TO_PDF = "/api/v1/convert/file/pdf"
HTML_TO_PDF = "/api/v1/convert/html/pdf"
IMG_TO_PDF = "/api/v1/convert/img/pdf"
MARKDOWN_TO_PDF = "/api/v1/convert/markdown/pdf"
PDF_TO_CBR = "/api/v1/convert/pdf/cbr"
PDF_TO_CBZ = "/api/v1/convert/pdf/cbz"
PDF_TO_CSV = "/api/v1/convert/pdf/csv"
PDF_TO_EPUB = "/api/v1/convert/pdf/epub"
PDF_TO_HTML = "/api/v1/convert/pdf/html"
PDF_TO_IMG = "/api/v1/convert/pdf/img"
PDF_TO_MARKDOWN = "/api/v1/convert/pdf/markdown"
PDF_TO_PDFA = "/api/v1/convert/pdf/pdfa"
PDF_TO_PRESENTATION = "/api/v1/convert/pdf/presentation"
PDF_TO_TEXT = "/api/v1/convert/pdf/text"
PDF_TO_TEXT_EDITOR = "/api/v1/convert/pdf/text-editor"
PDF_TO_VECTOR = "/api/v1/convert/pdf/vector"
PDF_TO_WORD = "/api/v1/convert/pdf/word"
PDF_TO_XLSX = "/api/v1/convert/pdf/xlsx"
PDF_TO_XML = "/api/v1/convert/pdf/xml"
SVG_TO_PDF = "/api/v1/convert/svg/pdf"
URL_TO_PDF = "/api/v1/convert/url/pdf"
VECTOR_TO_PDF = "/api/v1/convert/vector/pdf"
@@ -1698,8 +1639,9 @@ class ToolEndpoint(StrEnum):
EDIT_TEXT = "/api/v1/general/edit-text"
MERGE_PDFS = "/api/v1/general/merge-pdfs"
MULTI_PAGE_LAYOUT = "/api/v1/general/multi-page-layout"
OVERLAY_PDFS = "/api/v1/general/overlay-pdfs"
PDF_TO_SINGLE_PAGE = "/api/v1/general/pdf-to-single-page"
REARRANGE_PAGES = "/api/v1/general/rearrange-pages"
REMOVE_IMAGE_PDF = "/api/v1/general/remove-image-pdf"
REMOVE_PAGES = "/api/v1/general/remove-pages"
ROTATE_PDF = "/api/v1/general/rotate-pdf"
SCALE_PAGES = "/api/v1/general/scale-pages"
@@ -1708,33 +1650,31 @@ class ToolEndpoint(StrEnum):
SPLIT_PAGES = "/api/v1/general/split-pages"
SPLIT_PDF_BY_CHAPTERS = "/api/v1/general/split-pdf-by-chapters"
SPLIT_PDF_BY_SECTIONS = "/api/v1/general/split-pdf-by-sections"
ADD_ATTACHMENTS = "/api/v1/misc/add-attachments"
ADD_COMMENTS = "/api/v1/misc/add-comments"
ADD_IMAGE = "/api/v1/misc/add-image"
ADD_PAGE_NUMBERS = "/api/v1/misc/add-page-numbers"
ADD_STAMP = "/api/v1/misc/add-stamp"
AUTO_RENAME = "/api/v1/misc/auto-rename"
AUTO_SPLIT_PDF = "/api/v1/misc/auto-split-pdf"
COMPRESS_PDF = "/api/v1/misc/compress-pdf"
DELETE_ATTACHMENT = "/api/v1/misc/delete-attachment"
EXTRACT_ATTACHMENTS = "/api/v1/misc/extract-attachments"
EXTRACT_IMAGE_SCANS = "/api/v1/misc/extract-image-scans"
EXTRACT_IMAGES = "/api/v1/misc/extract-images"
FLATTEN = "/api/v1/misc/flatten"
OCR_PDF = "/api/v1/misc/ocr-pdf"
REMOVE_BLANKS = "/api/v1/misc/remove-blanks"
RENAME_ATTACHMENT = "/api/v1/misc/rename-attachment"
REPAIR = "/api/v1/misc/repair"
REPLACE_INVERT_PDF = "/api/v1/misc/replace-invert-pdf"
SCANNER_EFFECT = "/api/v1/misc/scanner-effect"
UNLOCK_PDF_FORMS = "/api/v1/misc/unlock-pdf-forms"
UPDATE_METADATA = "/api/v1/misc/update-metadata"
ADD_PASSWORD = "/api/v1/security/add-password"
ADD_WATERMARK = "/api/v1/security/add-watermark"
AUTO_REDACT = "/api/v1/security/auto-redact"
CERT_SIGN = "/api/v1/security/cert-sign"
PKCS11_CERTIFICATES = "/api/v1/security/cert-sign/hardware/pkcs11-certificates"
SESSIONS = "/api/v1/security/cert-sign/sessions"
VALIDATE_CERTIFICATE = "/api/v1/security/cert-sign/validate-certificate"
REDACT = "/api/v1/security/redact"
REDACT_EXECUTE = "/api/v1/security/redact-execute"
REMOVE_CERT_SIGN = "/api/v1/security/remove-cert-sign"
REMOVE_PASSWORD = "/api/v1/security/remove-password"
SANITIZE_PDF = "/api/v1/security/sanitize-pdf"
TIMESTAMP_PDF = "/api/v1/security/timestamp-pdf"
@@ -1745,20 +1685,24 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.CBZ_TO_PDF: CbzToPdfParams,
ToolEndpoint.EBOOK_TO_PDF: EbookToPdfParams,
ToolEndpoint.EML_TO_PDF: EmlToPdfParams,
ToolEndpoint.FILE_TO_PDF: FileToPdfParams,
ToolEndpoint.HTML_TO_PDF: HtmlToPdfParams,
ToolEndpoint.IMG_TO_PDF: ImgToPdfParams,
ToolEndpoint.MARKDOWN_TO_PDF: MarkdownToPdfParams,
ToolEndpoint.PDF_TO_CBR: PdfToCbrParams,
ToolEndpoint.PDF_TO_CBZ: PdfToCbzParams,
ToolEndpoint.PDF_TO_CSV: PdfToCsvParams,
ToolEndpoint.PDF_TO_EPUB: PdfToEpubParams,
ToolEndpoint.PDF_TO_HTML: PdfToHtmlParams,
ToolEndpoint.PDF_TO_IMG: PdfToImgParams,
ToolEndpoint.PDF_TO_MARKDOWN: PdfToMarkdownParams,
ToolEndpoint.PDF_TO_PDFA: PdfToPdfaParams,
ToolEndpoint.PDF_TO_PRESENTATION: PdfToPresentationParams,
ToolEndpoint.PDF_TO_TEXT: PdfToTextParams,
ToolEndpoint.PDF_TO_TEXT_EDITOR: PdfToTextEditorParams,
ToolEndpoint.PDF_TO_VECTOR: PdfToVectorParams,
ToolEndpoint.PDF_TO_WORD: PdfToWordParams,
ToolEndpoint.PDF_TO_XLSX: PdfToXlsxParams,
ToolEndpoint.PDF_TO_XML: PdfToXmlParams,
ToolEndpoint.SVG_TO_PDF: SvgToPdfParams,
ToolEndpoint.URL_TO_PDF: UrlToPdfParams,
ToolEndpoint.VECTOR_TO_PDF: VectorToPdfParams,
@@ -1768,8 +1712,9 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.EDIT_TEXT: EditTextParams,
ToolEndpoint.MERGE_PDFS: MergePdfsParams,
ToolEndpoint.MULTI_PAGE_LAYOUT: MultiPageLayoutParams,
ToolEndpoint.OVERLAY_PDFS: OverlayPdfsParams,
ToolEndpoint.PDF_TO_SINGLE_PAGE: PdfToSinglePageParams,
ToolEndpoint.REARRANGE_PAGES: RearrangePagesParams,
ToolEndpoint.REMOVE_IMAGE_PDF: RemoveImagePdfParams,
ToolEndpoint.REMOVE_PAGES: RemovePagesParams,
ToolEndpoint.ROTATE_PDF: RotatePdfParams,
ToolEndpoint.SCALE_PAGES: ScalePagesParams,
@@ -1778,33 +1723,31 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.SPLIT_PAGES: SplitPagesParams,
ToolEndpoint.SPLIT_PDF_BY_CHAPTERS: SplitPdfByChaptersParams,
ToolEndpoint.SPLIT_PDF_BY_SECTIONS: SplitPdfBySectionsParams,
ToolEndpoint.ADD_ATTACHMENTS: AddAttachmentsParams,
ToolEndpoint.ADD_COMMENTS: AddCommentsParams,
ToolEndpoint.ADD_IMAGE: AddImageParams,
ToolEndpoint.ADD_PAGE_NUMBERS: AddPageNumbersParams,
ToolEndpoint.ADD_STAMP: AddStampParams,
ToolEndpoint.AUTO_RENAME: AutoRenameParams,
ToolEndpoint.AUTO_SPLIT_PDF: AutoSplitPdfParams,
ToolEndpoint.COMPRESS_PDF: CompressPdfParams,
ToolEndpoint.DELETE_ATTACHMENT: DeleteAttachmentParams,
ToolEndpoint.EXTRACT_ATTACHMENTS: ExtractAttachmentsParams,
ToolEndpoint.EXTRACT_IMAGE_SCANS: ExtractImageScansParams,
ToolEndpoint.EXTRACT_IMAGES: ExtractImagesParams,
ToolEndpoint.FLATTEN: FlattenParams,
ToolEndpoint.OCR_PDF: OcrPdfParams,
ToolEndpoint.REMOVE_BLANKS: RemoveBlanksParams,
ToolEndpoint.RENAME_ATTACHMENT: RenameAttachmentParams,
ToolEndpoint.REPAIR: RepairParams,
ToolEndpoint.REPLACE_INVERT_PDF: ReplaceInvertPdfParams,
ToolEndpoint.SCANNER_EFFECT: ScannerEffectParams,
ToolEndpoint.UNLOCK_PDF_FORMS: UnlockPdfFormsParams,
ToolEndpoint.UPDATE_METADATA: UpdateMetadataParams,
ToolEndpoint.ADD_PASSWORD: AddPasswordParams,
ToolEndpoint.ADD_WATERMARK: AddWatermarkParams,
ToolEndpoint.AUTO_REDACT: AutoRedactParams,
ToolEndpoint.CERT_SIGN: CertSignParams,
ToolEndpoint.PKCS11_CERTIFICATES: Pkcs11CertificatesParams,
ToolEndpoint.SESSIONS: SessionsParams,
ToolEndpoint.VALIDATE_CERTIFICATE: ValidateCertificateParams,
ToolEndpoint.REDACT: RedactParams,
ToolEndpoint.REDACT_EXECUTE: RedactExecuteParams,
ToolEndpoint.REMOVE_CERT_SIGN: RemoveCertSignParams,
ToolEndpoint.REMOVE_PASSWORD: RemovePasswordParams,
ToolEndpoint.SANITIZE_PDF: SanitizePdfParams,
ToolEndpoint.TIMESTAMP_PDF: TimestampPdfParams,
+1
View File
@@ -11,6 +11,7 @@
# production
/build
/dist
/dist-portal
/storybook-static
/editor/build
+4 -2
View File
@@ -2,7 +2,7 @@
// the decorators below transpiles to React.createElement and needs React in
// scope. (The app + story files use the automatic runtime via the portal vite
// config; this import is specifically for the preview config file.)
import React, { useEffect } from "react";
import React, { Suspense, useEffect } from "react";
import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
@@ -109,7 +109,9 @@ const withProviders: Decorator = (Story, context) => {
<TierKey tier={tier}>
<UIProvider>
<ThemeWatcher />
<Story />
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</TierKey>
</LinkProvider>
+12 -1
View File
@@ -30,7 +30,18 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : "50%",
reporter: [["html", { open: "never" }], ["list"]],
// In CI, add a JSON report alongside the HTML/list output so the workflow
// can flag flaky tests (passed only on retry) as warnings without failing
// the job. Path is pinned via PLAYWRIGHT_JSON_OUTPUT_FILE in the workflow;
// the outputFile here is just a sane default. Omitted locally to keep dev
// runs' terminal output clean.
reporter: process.env.CI
? [
["html", { open: "never" }],
["list"],
["json", { outputFile: "playwright-report/results.json" }],
]
: [["html", { open: "never" }], ["list"]],
timeout: 60_000,
expect: { timeout: 10_000 },
+5 -2
View File
@@ -1,3 +1,6 @@
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer")],
import tailwindcssPostcss from "@tailwindcss/postcss";
import autoprefixer from "autoprefixer";
export default {
plugins: [tailwindcssPostcss, autoprefixer],
};
@@ -7430,7 +7430,7 @@ upgrade = "Upgrade"
volumeSuffix = "PDFs processed · last 30 days"
[portal.procurement]
enterpriseBadge = "Enterprise"
reset = "Reset procurement (demo)"
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
title = "Procurement"
@@ -7441,6 +7441,63 @@ request = "Request"
sign = "Review & sign"
upload = "Upload"
[portal.procurement.agreement]
agreeCta = "Agree & subscribe"
confirm = "I have read and agree to the Stirling Enterprise Agreement."
eyebrow = "Agreement"
intro = "One combined agreement covers your deal: Master Service Agreement, Order Form, EULA, and Data Processing Agreement. Review it, then agree to accept the quote into a committed subscription."
title = "Review your enterprise agreement"
[portal.procurement.builder]
addons = "Add-ons"
back = "Back"
businessName = "Business name"
businessNamePlaceholder = "Your company"
continue = "Continue"
country = "Country"
countryEuro = "Eurozone (EUR €)"
countryUK = "United Kingdom (GBP £)"
countryUS = "United States (USD $)"
eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote."
generate = "Generate quote"
included = "Included"
indemnification = "IP indemnification"
indemnificationSub = "We defend qualifying IP claims, per the EULA"
qbr = "Quarterly business reviews"
qbrSub = "Your SE reviews usage and roadmap each quarter"
running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
s1Sub = "Your team, and the PDFs you expect to run each year."
# Step 1 — volume
s1Title = "How much will you process?"
s2Sub = "Longer terms discount the rate; your service level sets support."
# Step 2 — commitment & service
s2Title = "Commitment and service"
s3Sub = "For the quote and the agreement it generates."
# Step 3 — details
s3Title = "Your details"
serviceLevel = "Service level"
slDedicated = "Dedicated"
slDedicatedSub = "4 business hours · dedicated account manager · +30%"
slPriority = "Priority"
slPrioritySub = "Same business day · named CSM · +15%"
slStandard = "Standard"
slStandardSub = "Next business day · shared CSM · included"
stepOf = "Step {{n}} of {{total}}"
term = "Term"
termDiscount = "{{pct}}% multi-year commitment discount applied"
title = "Build your quote"
training = "Onboarding & training"
trainingSub = "Live sessions to get your team running"
users = "Total users"
usersPlaceholder = "e.g. 250"
volEstimated = "Estimated from {{count}} users (~2,000 PDFs each, including automation). Edit if you know better."
volManual = "Using your figure. Re-estimate from your team size any time."
volNoUsers = "Not sure? Enter your team size and we'll estimate it."
volume = "Annual PDF volume"
volumePlaceholder = "e.g. 1,000,000"
years_one = "{{count}} year"
years_other = "{{count}} years"
[portal.procurement.docs]
count_one = "{{count}} doc"
count_other = "{{count}} docs"
@@ -7456,6 +7513,30 @@ supportingTitle = "Supporting your evaluation"
title = "Documents"
upcoming = "Upcoming"
[portal.procurement.error]
title = "Something went wrong"
[portal.procurement.hero]
company = "Your enterprise deal"
ctaAgreement = "Review & sign agreement"
ctaLive = "You're live"
ctaPayment = "Add payment"
ctaQuote = "Review your quote"
ctaTrial = "Build your quote"
eyebrow = "Enterprise procurement"
inviteTeammates = "Invite teammates"
keyDocs = "Key documents"
nextStep = "Next step: {{action}}"
notStarted = "Not started"
open = "Open procurement"
scheduleCall = "Schedule a call"
setup1Sub = "Invite your teammates"
setup1Title = "Deploy the PDF Editor"
setup2Sub = "Turn on processing across editors and other sources"
setup2Title = "Connect the PDF Processor"
setup3Sub = "Turn on Security, Compliance, Routing, or Retention when you need them"
setup3Title = "Add recommended policies"
[portal.procurement.journey]
daysLeft_one = "{{count}} day left"
daysLeft_other = "{{count}} days left"
@@ -7467,15 +7548,39 @@ subtitle = "Your solutions engineer is on every step. One next action at a time;
title = "From trial to live, one guided path"
trialTitle = "Enterprise trial"
[portal.procurement.link]
cta = "Link account"
description = "Procurement runs on your linked Stirling account: it's how we provision the trial, price your quote, and start billing. Link an account to start."
eyebrow = "Enterprise"
title = "Link your account to begin"
[portal.procurement.live]
description = "Your subscription is active and your licence is issued. Your team is provisioned and billing has started."
eyebrow = "Live"
title = "You're live on Stirling Enterprise"
[portal.procurement.locked]
description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation."
eyebrow = "Enterprise only"
talkToSales = "Talk to sales"
title = "The procurement track opens with Enterprise"
[portal.procurement.milestone]
accept = "Accept & continue"
description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes."
download = "Download PDF"
downloadError = "Could not download the quote PDF just yet — please try again in a moment."
edit = "Edit quote"
eyebrow = "Quote {{number}}"
perYear = " / yr"
preparedFor = "Prepared for {{company}}"
tcv = "{{value}} total contract value"
title = "Your quote is ready"
[portal.procurement.modal]
cancel = "Cancel"
chooseFile = "Choose file"
close = "Close"
downloadBody = "Your download will begin shortly."
downloadCta = "Download"
downloadTitle = "Download"
@@ -7494,6 +7599,13 @@ uploadBody = "Send us your PO and we invoice against it on your terms. Drag in t
uploadCta = "Upload purchase order"
uploadTitle = "Upload your purchase order"
[portal.procurement.payment]
description = "Your quote is accepted and a committed annual subscription has been created. Pay the first invoice to go live — you can pay or download it right here, no email needed."
downloadInvoice = "Download invoice"
simulate = "Simulate payment received (demo)"
title = "Subscription created"
viewInvoice = "View & pay invoice"
[portal.procurement.status]
action = "Action needed"
available = "Available"
@@ -7501,6 +7613,21 @@ complete = "Complete"
pending = "Pending"
request = "On request"
[portal.procurement.trial]
body = "Extending adds 7 days and notifies your solutions engineer."
bodyMaxed = "You have used all your extensions — talk to your solutions engineer if you need more time."
cancel = "Cancel trial"
extend = "Extend 7 days"
maxed = "Maxed out"
subtitle = "Your free trial runs through {{date}}. No card required."
title = "Enterprise trial"
[portal.procurement.upsell]
homeBadge = "Enterprise"
homeBody = "Committed volume pricing, org-wide SSO + SCIM + RBAC, 90-day immutable audit, and a dedicated SE."
homeCta = "Start Trial →"
homeHeadline = "Process millions of PDFs."
[portal.recentActivity]
title = "Recent activity"
viewAll = "View all"
+6 -6
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
const { icons } = require("@iconify-json/material-symbols");
const fs = require("fs");
const path = require("path");
import { icons } from "@iconify-json/material-symbols";
import fs from "node:fs";
import path from "node:path";
// Check for verbose flag
const isVerbose =
@@ -19,7 +19,7 @@ const debug = (message) => {
// Function to scan codebase for LocalIcon usage
function scanForUsedIcons() {
const usedIcons = new Set();
const srcDir = path.join(__dirname, "..", "src");
const srcDir = path.join(import.meta.dirname, "..", "src");
info("🔍 Scanning codebase for LocalIcon usage...");
@@ -140,7 +140,7 @@ async function main() {
// Check if we need to regenerate (compare with existing)
const outputPath = path.join(
__dirname,
import.meta.dirname,
"..",
"src",
"assets",
@@ -200,7 +200,7 @@ async function main() {
}
// Create output directory
const outputDir = path.join(__dirname, "..", "src", "assets");
const outputDir = path.join(import.meta.dirname, "..", "src", "assets");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
+7 -14
View File
@@ -1,28 +1,21 @@
#!/usr/bin/env node
const { execSync } = require("node:child_process");
const {
existsSync,
mkdirSync,
writeFileSync,
readFileSync,
} = require("node:fs");
const path = require("node:path");
import { execSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import path from "node:path";
import { argv } from "node:process";
const { argv } = require("node:process");
const inputIdx = argv.indexOf("--input");
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
const POSTPROCESS_ONLY = !!INPUT_FILE;
// __dirname is available in CommonJS by default
/**
* Generate 3rd party licenses for frontend dependencies
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
*/
const OUTPUT_FILE = path.join(
__dirname,
import.meta.dirname,
"..",
"src",
"assets",
@@ -30,7 +23,7 @@ const OUTPUT_FILE = path.join(
);
// package.json lives at the workspace root (frontend/), not editor/. The
// script is at frontend/editor/scripts/, so walk up two levels.
const PACKAGE_JSON = path.join(__dirname, "..", "..", "package.json");
const PACKAGE_JSON = path.join(import.meta.dirname, "..", "..", "package.json");
// Ensure the output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
@@ -192,7 +185,7 @@ try {
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(
__dirname,
import.meta.dirname,
"..",
"src",
"assets",
+25 -22
View File
@@ -16,11 +16,10 @@
/* global document, getComputedStyle */ // used inside page.evaluate (browser context)
import fs from "node:fs/promises";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
@@ -69,11 +68,14 @@ export const THEME = {
};
// ---- icon resolution (material-symbols via iconify) ------------------------
function resolveIcon(icon) {
async function resolveIcon(icon) {
if (!icon) return "";
if (icon.trim().startsWith("<svg")) return icon; // raw svg passed through
const { getIconData, iconToSVG } = require("@iconify/utils");
const set = require("@iconify-json/material-symbols/icons.json");
const { getIconData, iconToSVG } = await import("@iconify/utils");
const { default: set } = await import(
"@iconify-json/material-symbols/icons.json",
{ with: { type: "json" } }
);
const data = getIconData(set, icon);
if (!data) throw new Error(`icon not found in material-symbols: "${icon}"`);
const { attributes, body } = iconToSVG(data);
@@ -148,7 +150,7 @@ const escapeHtml = (s) =>
let _browser = null;
async function getBrowser() {
if (_browser) return _browser;
const puppeteer = require("puppeteer");
const { default: puppeteer } = await import("puppeteer");
_browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox"],
@@ -163,7 +165,7 @@ export async function renderOgCard({
outFile,
theme = THEME,
}) {
const iconSvg = resolveIcon(icon);
const iconSvg = await resolveIcon(icon);
const html = await buildHtml({ name, description, iconSvg, theme });
const browser = await getBrowser();
const page = await browser.newPage();
@@ -230,7 +232,7 @@ const kebab = (id) => id.replace(/([A-Z])/g, "-$1").toLowerCase();
// English name/description live next to each tool as the `t(key, fallback)` default.
function readRegistryStrings() {
const src = require("node:fs").readFileSync(
const src = readFileSync(
path.join(ROOT, "src/core/data/useTranslatedToolRegistry.tsx"),
"utf8",
);
@@ -268,7 +270,7 @@ export async function generateMissing(theme = THEME) {
// Each tool's app icon lives as `icon="<material-symbol>"` just before its
// `name: t("home.<id>.title", …)`. Pair each title with the closest preceding icon.
function readRegistryIcons() {
const src = require("node:fs").readFileSync(
const src = readFileSync(
path.join(ROOT, "src/core/data/useTranslatedToolRegistry.tsx"),
"utf8",
);
@@ -288,25 +290,26 @@ function readRegistryIcons() {
return byId;
}
function iconExists(name) {
async function iconExists(name) {
if (!name) return false;
try {
const { getIconData } = require("@iconify/utils");
return !!getIconData(
require("@iconify-json/material-symbols/icons.json"),
name,
const { getIconData } = await import("@iconify/utils");
const { default: set } = await import(
"@iconify-json/material-symbols/icons.json",
{ with: { type: "json" } }
);
return !!getIconData(set, name);
} catch {
return false;
}
}
// First candidate that resolves; also tries dropping a "-rounded" suffix.
function firstResolvableIcon(candidates) {
async function firstResolvableIcon(candidates) {
for (const c of candidates) {
if (iconExists(c)) return c;
if (await iconExists(c)) return c;
const alt = c && c.replace(/-rounded$/, "");
if (alt && alt !== c && iconExists(alt)) return alt;
if (alt && alt !== c && (await iconExists(alt))) return alt;
}
return "description-outline";
}
@@ -324,14 +327,14 @@ export async function generateAll(theme = THEME) {
const { titles, descs } = readRegistryStrings();
const regIcons = readRegistryIcons();
const ogMap = JSON.parse(
require("node:fs").readFileSync(
path.join(ROOT, "src/core/data/ogImageMap.json"),
"utf8",
),
readFileSync(path.join(ROOT, "src/core/data/ogImageMap.json"), "utf8"),
);
const results = [];
for (const [id, basename] of Object.entries(ogMap)) {
const icon = firstResolvableIcon([regIcons[id], MISSING_TOOL_ICONS[id]]);
const icon = await firstResolvableIcon([
regIcons[id],
MISSING_TOOL_ICONS[id],
]);
await renderOgCard({
name: titles[id] || humanizeId(id),
description: descs[id] || "",
@@ -0,0 +1,400 @@
/**
* Generates the committed frontend tool API types (toolApiTypes.ts) from the
* Java backend's OpenAPI spec, so the frontend's request shapes stay in step
* with the backend.
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { parseArgs } from "node:util";
import { compile, type JSONSchema } from "json-schema-to-typescript";
import * as prettier from "prettier";
// The API namespaces whose endpoints are real, callable tools. `/api/v1/filter/`
// (pipeline-only) and `/api/v1/ai/tools/` (not in the spec) are intentionally
// excluded. Extend this list when other namespaces become tools.
const ALLOWED_PATH_PREFIXES = [
"/api/v1/general/",
"/api/v1/misc/",
"/api/v1/security/",
"/api/v1/convert/",
];
// File plumbing, not user parameters: `fileInput` is the uploaded document and
// `fileId` a server-side handle. Stripped from every generated request model.
// Named file fields (stampImage, attachments, ...) are real parameters and kept.
const BASE_FILE_FIELDS = new Set(["fileInput", "fileId"]);
// The shared "upload a file or provide a file ID" wrapper schema and its two
// branches. An endpoint whose body is exactly this has no parameters, so it must
// resolve to an empty model. It needs separate handling because the wrapper is a
// `oneOf`, which survives the flat-field stripping above and would otherwise leak
// the file fields into the output.
const FILE_WRAPPER_COMPONENTS = new Set([
"PDFFile",
"PDFFileUpload",
"PDFFileRef",
]);
const COMPONENT_REF_PREFIX = "#/components/schemas/";
const FILE_HEADER = [
"// AUTO-GENERATED FILE. DO NOT EDIT.",
"// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec",
"// (SwaggerDoc.json). Regenerate with: task frontend:tool-models",
"// Tools that take only a file input have no parameters; their model is Record<string, never>.",
].join("\n");
type Json = Record<string, unknown>;
interface DiscoveredTool {
path: string;
className: string;
}
function isObject(value: unknown): value is Json {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Recursively sort object keys so the output is byte-stable regardless of the
* key ordering springdoc happens to emit.
*/
function deepSortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(deepSortKeys);
if (isObject(value)) {
const sorted: Json = {};
for (const key of Object.keys(value).sort()) {
sorted[key] = deepSortKeys(value[key]);
}
return sorted;
}
return value;
}
function pascalCase(segment: string): string {
return segment
.split(/[-_/]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
/** Fallback class name for an inline request body (no $ref to name it after). */
function pathToClassName(path: string): string {
const relevant = path.replace(/^\/api\/v1\//, "");
return `${pascalCase(relevant)}Request`;
}
function dedupe(name: string, used: Set<string>): string {
let candidate = name;
let n = 2;
while (used.has(candidate)) candidate = `${name}${n++}`;
used.add(candidate);
return candidate;
}
/** The request body schema for a POST endpoint (multipart wins, then JSON), or null. */
function requestBodySchema(pathItem: Json): Json | null {
const post = pathItem.post;
if (!isObject(post)) return null;
const requestBody = post.requestBody;
if (!isObject(requestBody)) return null;
const content = requestBody.content;
if (!isObject(content)) return null;
for (const mediaType of ["multipart/form-data", "application/json"]) {
const entry = content[mediaType];
if (isObject(entry) && isObject(entry.schema)) return entry.schema;
}
return null;
}
/**
* A POST endpoint's query parameters as a property map plus the required ones.
* Some tools take inputs on the query string alongside the multipart body (e.g.
* merge-pdfs' `fileOrder`), so a complete model has to fold them in. Ref-valued
* param schemas are inlined later by rewriteRefs.
*/
function queryParameters(pathItem: Json): { props: Json; required: string[] } {
const props: Json = {};
const required: string[] = [];
const post = pathItem.post;
if (!isObject(post) || !Array.isArray(post.parameters))
return { props, required };
for (const param of post.parameters) {
if (
!isObject(param) ||
param.in !== "query" ||
typeof param.name !== "string"
)
continue;
if (!isObject(param.schema)) continue;
const schema = structuredClone(param.schema) as Json;
if (!("description" in schema) && typeof param.description === "string") {
schema.description = param.description;
}
props[param.name] = schema;
if (param.required === true) required.push(param.name);
}
return { props, required };
}
/**
* Rewrite every `#/components/schemas/X` ref to `#/definitions/X` in place (the
* form json-schema-to-typescript expects) and collect the referenced component
* names so the caller can inline them.
*/
function rewriteRefs(node: unknown, found: Set<string>): void {
if (Array.isArray(node)) {
for (const item of node) rewriteRefs(item, found);
return;
}
if (!isObject(node)) return;
const ref = node.$ref;
if (typeof ref === "string" && ref.startsWith(COMPONENT_REF_PREFIX)) {
const name = ref.slice(COMPONENT_REF_PREFIX.length);
node.$ref = `#/definitions/${name}`;
found.add(name);
}
for (const value of Object.values(node)) rewriteRefs(value, found);
}
/** Keep only fields the client must send: drop those with a default or already stripped. */
function computeRequired(schema: Json, properties: Json): string[] {
const required = Array.isArray(schema.required)
? (schema.required as string[])
: [];
return required.filter((name) => {
const prop = properties[name];
return name in properties && !(isObject(prop) && "default" in prop);
});
}
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
spec: { type: "string" },
output: { type: "string" },
check: { type: "boolean", default: false },
},
});
if (!values.spec || !values.output) {
throw new Error(
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> [--check]",
);
}
const specPath = resolve(values.spec);
const outputPath = resolve(values.output);
const spec = JSON.parse(readFileSync(specPath, "utf-8")) as Json;
const paths = isObject(spec.paths) ? spec.paths : {};
const components =
isObject(spec.components) && isObject(spec.components.schemas)
? spec.components.schemas
: {};
const tools: DiscoveredTool[] = [];
const definitions: Record<string, Json> = {};
const usedClassNames = new Set<string>();
const pendingComponents = new Set<string>();
const skipped: string[] = [];
for (const path of Object.keys(paths).sort()) {
if (
path.includes("{") ||
!ALLOWED_PATH_PREFIXES.some((p) => path.startsWith(p))
)
continue;
const pathItem = paths[path];
if (!isObject(pathItem)) continue;
const bodySchema = requestBodySchema(pathItem);
if (!bodySchema) {
if (isObject(pathItem.post)) skipped.push(path);
continue;
}
// Resolve the request model into a fresh, mutable clone so we never mutate the shared spec.
const ref = bodySchema.$ref;
const refComponent =
typeof ref === "string" && ref.startsWith(COMPONENT_REF_PREFIX)
? ref.slice(COMPONENT_REF_PREFIX.length)
: null;
let className: string;
let modelSchema: Json;
if (refComponent && FILE_WRAPPER_COMPONENTS.has(refComponent)) {
// File-only endpoint: model it as an empty object so it becomes
// Record<string, never> rather than the wrapper's file union. Named after
// the path since the wrapper schema is shared. Query params still fold in
// below.
className = pathToClassName(path);
modelSchema = { type: "object", properties: {} };
} else if (refComponent) {
const component = components[refComponent];
if (!isObject(component)) continue;
className = refComponent;
modelSchema = structuredClone(component) as Json;
} else {
className = pathToClassName(path);
modelSchema = structuredClone(bodySchema) as Json;
}
// A component shared by several endpoints (e.g. GeneralFile) is only defined once.
if (!(className in definitions)) {
const uniqueName = dedupe(className, usedClassNames);
className = uniqueName;
const bodyProps: Json = isObject(modelSchema.properties)
? (structuredClone(modelSchema.properties) as Json)
: {};
const query = queryParameters(pathItem);
// Body wins over query on a name collision.
const properties: Json = { ...query.props, ...bodyProps };
for (const field of BASE_FILE_FIELDS) delete properties[field];
modelSchema.properties = properties;
const required = new Set(computeRequired(modelSchema, properties));
for (const name of query.required) {
const prop = properties[name];
if (name in properties && !(isObject(prop) && "default" in prop)) {
required.add(name);
}
}
if (required.size > 0) modelSchema.required = [...required];
else delete modelSchema.required;
modelSchema.title = className;
rewriteRefs(modelSchema, pendingComponents);
definitions[className] = modelSchema;
}
tools.push({ path, className });
}
// Transitively inline every referenced component into `definitions`, rewriting its refs too.
const queue = [...pendingComponents];
while (queue.length > 0) {
const name = queue.pop() as string;
if (name in definitions) continue;
const component = components[name];
if (!isObject(component)) continue;
const cloned = structuredClone(component) as Json;
cloned.title = name;
const nested = new Set<string>();
rewriteRefs(cloned, nested);
definitions[name] = cloned;
for (const next of nested) if (!(next in definitions)) queue.push(next);
}
await compileAndWrite(
tools,
definitions,
outputPath,
values.check ?? false,
skipped,
);
}
async function compileAndWrite(
tools: DiscoveredTool[],
definitions: Record<string, Json>,
outputPath: string,
check: boolean,
skipped: string[],
): Promise<void> {
// json-schema-to-typescript only emits a named, exported interface per schema
// if something references it, so wrap every model in one root object. The root
// interface itself is stripped from the output afterwards.
const rootName = "__ToolApiRootAutogen";
const uniqueClassNames = [...new Set(tools.map((t) => t.className))];
const rootSchema: JSONSchema = {
title: rootName,
type: "object",
additionalProperties: false,
properties: Object.fromEntries(
uniqueClassNames.map((name) => [name, { $ref: `#/definitions/${name}` }]),
),
definitions: definitions as Record<string, JSONSchema>,
};
// Canonicalize key order so a reordering in SwaggerDoc.json can never change
// the generated file (which would flake the committed-types CI check).
const canonicalRoot = deepSortKeys(rootSchema) as JSONSchema;
const compiled = await compile(canonicalRoot, rootName, {
bannerComment: "",
additionalProperties: false,
declareExternallyReferenced: true,
unreachableDefinitions: false,
strictIndexSignatures: true,
format: false,
});
// Drop the root wrapper interface, then rewrite empty models (file-only tools)
// to `Record<string, never>` - the precise, lint-clean type for an object with
// no properties (json-schema-to-typescript always emits `{}` interfaces here).
const models = compiled
.replace(new RegExp(`export interface ${rootName} \\{[^}]*\\}`), "")
.replace(
/export interface (\w+) \{\s*\}/g,
"export type $1 = Record<string, never>;",
)
.trim();
const endpointUnion = tools
.map((t) => ` | ${JSON.stringify(t.path)}`)
.join("\n");
const paramsEntries = tools
.map((t) => ` ${JSON.stringify(t.path)}: ${t.className};`)
.join("\n");
const endpointList = tools
.map((t) => ` ${JSON.stringify(t.path)},`)
.join("\n");
const footer = [
"/** Endpoint path for a generated tool operation (the operation identity across languages). */",
`export type ToolEndpoint =\n${endpointUnion};`,
"",
"/** Backend request-parameter model for each tool endpoint. */",
`export interface ToolApiParams {\n${paramsEntries}\n}`,
"",
"/** Every generated tool endpoint, for iteration. */",
`export const TOOL_ENDPOINTS = [\n${endpointList}\n] as const satisfies readonly ToolEndpoint[];`,
"",
"/** Union of every generated tool request model. */",
`export type ToolApiRequest = ToolApiParams[ToolEndpoint];`,
].join("\n");
const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`;
const prettierConfig = await prettier.resolveConfig(outputPath);
const formatted = await prettier.format(body, {
...prettierConfig,
parser: "typescript",
});
if (check) {
let current = "";
try {
current = readFileSync(outputPath, "utf-8");
} catch {
// Missing file counts as out of date.
}
if (current !== formatted) {
throw new Error(
`${outputPath} is out of date. Run 'task frontend:tool-models' and commit the result.`,
);
}
console.log(`Up to date: ${tools.length} tool endpoints.`);
return;
}
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, formatted, "utf-8");
console.log(`Generated ${tools.length} tool endpoints -> ${outputPath}`);
if (skipped.length > 0) {
console.log(
`Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`,
);
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
@@ -0,0 +1,127 @@
// Reads a Playwright JSON report and surfaces "flaky" tests (tests that
// failed at least once, then passed on retry) in GitHub Actions WITHOUT
// failing the job:
// - emits one ::warning:: workflow command per flaky test, so the run and
// PR show a yellow warning triangle + count, and the annotation links to
// the test's source line
// - appends a summary table to the job summary ($GITHUB_STEP_SUMMARY)
//
// A green-but-flaky job is otherwise invisible (Playwright exits 0 once a
// retry passes), which lets flakes accrete unnoticed. This makes them visible
// without turning them into hard failures.
//
// Run: `npx tsx editor/scripts/report-flaky-tests.mts <results.json> [more.json...]`
// (a single path is also read from PLAYWRIGHT_JSON_OUTPUT_FILE). Multiple
// reports are merged + de-duplicated, so a job that runs Playwright in
// several segments (e.g. the enterprise OAuth/SAML/license phases) can
// pass one report per phase. A missing report or zero flaky tests is a
// silent no-op, so it is safe to run with `if: always()` after any
// Playwright step.
import { appendFileSync, existsSync, readFileSync } from "fs";
import { isAbsolute, join, relative } from "path";
import type { JSONReport, JSONReportSuite } from "@playwright/test/reporter";
interface FlakyTest {
file: string;
line: number;
title: string;
}
// Playwright records each test's outcome as expected|unexpected|flaky|skipped.
// "flaky" means it needed a retry to pass, which is exactly what we surface.
function collectFlaky(
report: JSONReport,
workspace: string,
rootDir: string,
): FlakyTest[] {
const flaky: FlakyTest[] = [];
const walk = (suite: JSONReportSuite, trail: string[], depth: number) => {
// The outermost suite per file has title === the file path; skip it so the
// human-readable title is just "describe > test" (the path is shown
// separately as the location). Nested suites are the describe() blocks.
const titles = depth > 0 && suite.title ? [...trail, suite.title] : trail;
for (const spec of suite.specs ?? []) {
if ((spec.tests ?? []).some((t) => t.status === "flaky")) {
const abs = spec.file
? isAbsolute(spec.file)
? spec.file
: join(rootDir, spec.file)
: "";
const rel = abs ? relative(workspace, abs) : "";
flaky.push({
// Drop the path from the annotation if it escapes the workspace, so
// we never emit a broken file= link (the warning still shows).
file: rel && !rel.startsWith("..") ? rel : "",
line: spec.line || 0,
title: [...titles, spec.title].filter(Boolean).join(" > "),
});
}
}
for (const child of suite.suites ?? []) walk(child, titles, depth + 1);
};
for (const suite of report.suites ?? []) walk(suite, [], 0);
return flaky;
}
// Deliberately no process.exit() calls: every path falls through to a natural
// exit(0). This step must never fail the job, and it keeps CI green even when
// the report is missing or clean.
function main(): void {
// Accept one or more report paths: a job may run Playwright in several
// segments, each writing its own report (the enterprise job does this for
// OAuth / SAML / license phases). Fall back to the env var when no paths are
// passed. Missing files are skipped, not fatal.
const reportPaths = process.argv.slice(2);
const envPath = process.env.PLAYWRIGHT_JSON_OUTPUT_FILE;
if (reportPaths.length === 0 && envPath) {
reportPaths.push(envPath);
}
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const seen = new Set<string>();
const flaky: FlakyTest[] = [];
for (const reportPath of reportPaths) {
if (!reportPath || !existsSync(reportPath)) {
// No report (e.g. the build failed before this segment ran).
continue;
}
const report = JSON.parse(readFileSync(reportPath, "utf8")) as JSONReport;
const rootDir = report.config?.rootDir || process.cwd();
for (const test of collectFlaky(report, workspace, rootDir)) {
const key = `${test.file}:${test.line}:${test.title}`;
if (!seen.has(key)) {
seen.add(key);
flaky.push(test);
}
}
}
if (flaky.length === 0) {
return;
}
for (const f of flaky) {
const loc = f.file ? `file=${f.file},line=${f.line},` : "";
process.stdout.write(
`::warning ${loc}title=Flaky test::${f.title} passed only on retry\n`,
);
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (summaryPath) {
const plural = flaky.length === 1 ? "" : "s";
const lines = [
`### :warning: ${flaky.length} flaky test${plural} (passed on retry)`,
"",
"These passed, but not on the first attempt. Worth fixing before they turn into hard failures.",
"",
"| Test | Location |",
"| --- | --- |",
...flaky.map((f) => `| ${f.title} | \`${f.file || "?"}:${f.line}\` |`),
"",
];
appendFileSync(summaryPath, lines.join("\n") + "\n");
}
}
main();
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling PDF",
"mainBinaryName": "Stirling-PDF",
"version": "2.14.0",
"version": "2.14.1",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -3,35 +3,83 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddPageNumbersParameters,
defaultParameters,
} from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
const ENDPOINT = "/api/v1/misc/add-page-numbers" satisfies ToolEndpoint;
type AddPageNumbersApiParams = ToolApiParams[typeof ENDPOINT];
// The UI labels fonts capitalized while the backend model uses lowercase; these
// maps translate between them so both mappers type-check without casting.
const FONT_TYPE_TO_API = {
Times: "times",
Helvetica: "helvetica",
Courier: "courier",
} as const satisfies Record<
AddPageNumbersParameters["fontType"],
AddPageNumbersApiParams["fontType"]
>;
const FONT_TYPE_FROM_API = {
times: "Times",
helvetica: "Helvetica",
courier: "Courier",
} as const satisfies Record<
AddPageNumbersApiParams["fontType"],
AddPageNumbersParameters["fontType"]
>;
// Convert the tool's UI parameters into the add-page-numbers request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const addPageNumbersToApiParams = (
parameters: AddPageNumbersParameters,
): AddPageNumbersApiParams => ({
customMargin: parameters.customMargin,
position: parameters.position,
fontSize: parameters.fontSize,
fontType: FONT_TYPE_TO_API[parameters.fontType],
startingNumber: parameters.startingNumber,
pagesToNumber: parameters.pagesToNumber,
customText: parameters.customText,
zeroPad: parameters.zeroPad,
});
// Reconstruct the tool's UI parameters from an add-page-numbers request body,
// so a stored or AI-authored step can be re-rendered in the settings UI.
export const addPageNumbersFromApiParams = (
apiParams: AddPageNumbersApiParams,
): Partial<AddPageNumbersParameters> => ({
customMargin: apiParams.customMargin,
position: apiParams.position,
fontSize: apiParams.fontSize,
fontType: FONT_TYPE_FROM_API[apiParams.fontType],
startingNumber: apiParams.startingNumber,
pagesToNumber: apiParams.pagesToNumber,
customText: apiParams.customText,
zeroPad: apiParams.zeroPad,
});
export const buildAddPageNumbersFormData = (
parameters: AddPageNumbersParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("customMargin", parameters.customMargin);
formData.append("position", String(parameters.position));
formData.append("fontSize", String(parameters.fontSize));
formData.append("fontType", parameters.fontType);
formData.append("startingNumber", String(parameters.startingNumber));
formData.append("pagesToNumber", parameters.pagesToNumber);
formData.append("customText", parameters.customText);
formData.append("zeroPad", String(parameters.zeroPad));
return formData;
};
): FormData =>
objectToFormData(addPageNumbersToApiParams(parameters), { fileInput: file });
export const addPageNumbersOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddPageNumbersFormData,
toApiParams: addPageNumbersToApiParams,
fromApiParams: addPageNumbersFromApiParams,
operationType: "addPageNumbers",
endpoint: "/api/v1/misc/add-page-numbers",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -3,51 +3,97 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddStampParameters,
defaultParameters,
} from "@app/components/tools/addStamp/useAddStampParameters";
const ENDPOINT = "/api/v1/misc/add-stamp" satisfies ToolEndpoint;
type AddStampApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the add-stamp request body. The stamp
// image itself is a File and is passed via the `files` argument, not here.
export const addStampToApiParams = (
parameters: AddStampParameters,
): AddStampApiParams => {
const stampType = parameters.stampType || "text";
const apiParams: AddStampApiParams = {
stampType,
pageNumbers: parameters.pageNumbers,
customMargin: parameters.customMargin || "medium",
position: parameters.position,
fontSize: parameters.fontSize,
rotation: parameters.rotation,
// The UI stores opacity as a 0-100 percentage; the backend expects 0.0-1.0.
opacity: parameters.opacity / 100,
overrideX: parameters.overrideX,
overrideY: parameters.overrideY,
customColor: parameters.customColor.startsWith("#")
? parameters.customColor
: `#${parameters.customColor}`,
alphabet: parameters.alphabet,
};
if (stampType === "text") {
apiParams.stampText = parameters.stampText;
}
return apiParams;
};
// Reconstruct the tool's UI parameters from an add-stamp request body, so a
// stored or AI-authored step can be re-rendered in the settings UI. The stamp
// image File cannot be recovered from the request model.
export const addStampFromApiParams = (
apiParams: AddStampApiParams,
): Partial<AddStampParameters> => {
const result: Partial<AddStampParameters> = {
stampType: apiParams.stampType,
pageNumbers: apiParams.pageNumbers,
customMargin: apiParams.customMargin,
position: apiParams.position,
fontSize: apiParams.fontSize,
rotation: apiParams.rotation,
overrideX: apiParams.overrideX,
overrideY: apiParams.overrideY,
customColor: apiParams.customColor,
alphabet: apiParams.alphabet,
};
if (apiParams.opacity !== undefined) {
result.opacity = apiParams.opacity * 100;
}
if (apiParams.stampText !== undefined) {
result.stampText = apiParams.stampText;
}
return result;
};
export const buildAddStampFormData = (
parameters: AddStampParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("pageNumbers", parameters.pageNumbers);
formData.append("customMargin", parameters.customMargin || "medium");
formData.append("position", String(parameters.position));
const effectiveFontSize = parameters.fontSize;
formData.append("fontSize", String(effectiveFontSize));
formData.append("rotation", String(parameters.rotation));
formData.append("opacity", String(parameters.opacity / 100));
formData.append("overrideX", String(parameters.overrideX));
formData.append("overrideY", String(parameters.overrideY));
formData.append(
"customColor",
parameters.customColor.startsWith("#")
? parameters.customColor
: `#${parameters.customColor}`,
): FormData =>
objectToFormData(
addStampToApiParams(parameters),
parameters.stampType === "image" && parameters.stampImage
? { fileInput: file, stampImage: parameters.stampImage }
: { fileInput: file },
);
formData.append("alphabet", parameters.alphabet);
// Stamp type and payload
formData.append("stampType", parameters.stampType || "text");
if (parameters.stampType === "text") {
formData.append("stampText", parameters.stampText);
} else if (parameters.stampType === "image" && parameters.stampImage) {
formData.append("stampImage", parameters.stampImage);
}
return formData;
};
export const addStampOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddStampFormData,
toApiParams: addStampToApiParams,
fromApiParams: addStampFromApiParams,
operationType: "addStamp",
endpoint: "/api/v1/misc/add-stamp",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -4,37 +4,59 @@ import {
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
import {
AddAttachmentsParameters,
DEFAULT_ADD_ATTACHMENTS_PARAMETERS,
} from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
const ENDPOINT = "/api/v1/misc/add-attachments" satisfies ToolEndpoint;
type AddAttachmentsApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the add-attachments request body. The
// attachment files are uploaded via the named "attachments" field (see
// buildFormData); the model lists them but they are not scalar parameters.
export const addAttachmentsToApiParams = (
parameters: AddAttachmentsParameters,
): AddAttachmentsApiParams => ({
attachments: [],
convertToPdfA3b: parameters.convertToPdfA3b,
});
// Reconstruct the tool's UI parameters from an add-attachments request body (the
// attachment files themselves are not recoverable from stored parameters).
export const addAttachmentsFromApiParams = (
apiParams: AddAttachmentsApiParams,
): Partial<AddAttachmentsParameters> => ({
convertToPdfA3b:
apiParams.convertToPdfA3b ??
DEFAULT_ADD_ATTACHMENTS_PARAMETERS.convertToPdfA3b,
});
const buildFormData = (
parameters: AddAttachmentsParameters,
file: File,
): FormData => {
const formData = new FormData();
// Add the main PDF file (single file per request in singleFile mode)
if (file) {
formData.append("fileInput", file);
}
// Add attachment files
(parameters.attachments || []).forEach((attachment) => {
if (attachment) formData.append("attachments", attachment);
): FormData =>
objectToFormData(addAttachmentsToApiParams(parameters), {
fileInput: file,
attachments: (parameters.attachments || []).filter(Boolean),
});
formData.append("convertToPdfA3b", String(parameters.convertToPdfA3b));
return formData;
};
// Operation configuration for automation
export const addAttachmentsOperationConfig: ToolOperationConfig<AddAttachmentsParameters> =
{
toolType: ToolType.singleFile,
buildFormData,
toApiParams: addAttachmentsToApiParams,
fromApiParams: addAttachmentsFromApiParams,
operationType: "addAttachments",
endpoint: "/api/v1/misc/add-attachments",
endpoint: ENDPOINT,
defaultParameters: DEFAULT_ADD_ATTACHMENTS_PARAMETERS,
};
export const useAddAttachmentsOperation = () => {
@@ -1,6 +1,10 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useAddPasswordOperation } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
import {
addPasswordFromApiParams,
addPasswordToApiParams,
useAddPasswordOperation,
} from "@app/hooks/tools/addPassword/useAddPasswordOperation";
import type { AddPasswordFullParameters } from "@app/hooks/tools/addPassword/useAddPasswordParameters";
// Mock the useToolOperation hook
@@ -141,3 +145,48 @@ describe("useAddPasswordOperation", () => {
expect(callArgs[property]).toBe(expectedValue);
});
});
describe("addPassword mappers", () => {
test("round-trips backend params, including the flattened permissions", () => {
// Baseline differs from the configured values so the round trip fails if
// fromApiParams drops a field instead of reconstructing it.
const baseline: AddPasswordFullParameters = {
password: "",
ownerPassword: "",
keyLength: 40,
permissions: {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
},
};
const configured: AddPasswordFullParameters = {
password: "user-pw",
ownerPassword: "owner-pw",
keyLength: 128,
permissions: {
preventAssembly: true,
preventExtractContent: false,
preventExtractForAccessibility: true,
preventFillInForm: false,
preventModify: true,
preventModifyAnnotations: false,
preventPrinting: true,
preventPrintingFaithful: false,
},
};
const api = addPasswordToApiParams(configured);
const roundTripped = addPasswordToApiParams({
...baseline,
...addPasswordFromApiParams(api),
});
expect(roundTripped).toEqual(api);
});
});
@@ -3,29 +3,80 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddPasswordFullParameters,
defaultParameters,
} from "@app/hooks/tools/addPassword/useAddPasswordParameters";
import { defaultParameters as permissionsDefaults } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
import { getFormData } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
const ENDPOINT = "/api/v1/security/add-password" satisfies ToolEndpoint;
type AddPasswordApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the add-password request body. The
// permissions sub-object is flattened into the request's prevent* fields.
export const addPasswordToApiParams = (
parameters: AddPasswordFullParameters,
): AddPasswordApiParams => ({
password: parameters.password,
ownerPassword: parameters.ownerPassword,
// The UI stores keyLength as a number; narrow it to the model's allowed sizes.
keyLength: parameters.keyLength as AddPasswordApiParams["keyLength"],
preventAssembly: parameters.permissions.preventAssembly ?? false,
preventExtractContent: parameters.permissions.preventExtractContent ?? false,
preventExtractForAccessibility:
parameters.permissions.preventExtractForAccessibility ?? false,
preventFillInForm: parameters.permissions.preventFillInForm ?? false,
preventModify: parameters.permissions.preventModify ?? false,
preventModifyAnnotations:
parameters.permissions.preventModifyAnnotations ?? false,
preventPrinting: parameters.permissions.preventPrinting ?? false,
preventPrintingFaithful:
parameters.permissions.preventPrintingFaithful ?? false,
});
// Reconstruct the tool's UI parameters from an add-password request body, so a
// stored or AI-authored step can be re-rendered in the settings UI.
export const addPasswordFromApiParams = (
apiParams: AddPasswordApiParams,
): Partial<AddPasswordFullParameters> => ({
password: apiParams.password ?? defaultParameters.password,
ownerPassword: apiParams.ownerPassword ?? defaultParameters.ownerPassword,
keyLength: apiParams.keyLength,
permissions: {
preventAssembly:
apiParams.preventAssembly ?? permissionsDefaults.preventAssembly,
preventExtractContent:
apiParams.preventExtractContent ??
permissionsDefaults.preventExtractContent,
preventExtractForAccessibility:
apiParams.preventExtractForAccessibility ??
permissionsDefaults.preventExtractForAccessibility,
preventFillInForm:
apiParams.preventFillInForm ?? permissionsDefaults.preventFillInForm,
preventModify: apiParams.preventModify ?? permissionsDefaults.preventModify,
preventModifyAnnotations:
apiParams.preventModifyAnnotations ??
permissionsDefaults.preventModifyAnnotations,
preventPrinting:
apiParams.preventPrinting ?? permissionsDefaults.preventPrinting,
preventPrintingFaithful:
apiParams.preventPrintingFaithful ??
permissionsDefaults.preventPrintingFaithful,
},
});
// Static function that can be used by both the hook and automation executor
export const buildAddPasswordFormData = (
parameters: AddPasswordFullParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
formData.append("ownerPassword", parameters.ownerPassword);
formData.append("keyLength", parameters.keyLength.toString());
getFormData(parameters.permissions).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
): FormData =>
objectToFormData(addPasswordToApiParams(parameters), { fileInput: file });
// Full default parameters including permissions for automation
const fullDefaultParameters: AddPasswordFullParameters = {
@@ -37,8 +88,10 @@ const fullDefaultParameters: AddPasswordFullParameters = {
export const addPasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddPasswordFormData,
toApiParams: addPasswordToApiParams,
fromApiParams: addPasswordFromApiParams,
operationType: "addPassword",
endpoint: "/api/v1/security/add-password",
endpoint: ENDPOINT,
defaultParameters: fullDefaultParameters,
} as const;
@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import {
addWatermarkFromApiParams,
addWatermarkToApiParams,
} from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
import {
AddWatermarkParameters,
defaultParameters,
} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
describe("addWatermark mappers", () => {
// opacity 33 exercises the percentage <-> fraction conversion (/100, *100),
// which must survive the round trip without drifting on floating point.
test.each<Partial<AddWatermarkParameters>>([
{ watermarkType: "text", watermarkText: "DRAFT", opacity: 33 },
{ watermarkType: "image", opacity: 33 },
])("round-trips backend params for %o", (overrides) => {
const api = addWatermarkToApiParams({ ...defaultParameters, ...overrides });
const roundTripped = addWatermarkToApiParams({
...defaultParameters,
...addWatermarkFromApiParams(api),
});
expect(roundTripped).toEqual(api);
});
});
@@ -3,57 +3,97 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddWatermarkParameters,
defaultParameters,
} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
const ENDPOINT = "/api/v1/security/add-watermark" satisfies ToolEndpoint;
type AddWatermarkApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the add-watermark request body. The
// watermark image itself is a File and is passed via the `files` argument.
export const addWatermarkToApiParams = (
parameters: AddWatermarkParameters,
): AddWatermarkApiParams => {
const watermarkType = parameters.watermarkType || "text";
const apiParams: AddWatermarkApiParams = {
watermarkType,
fontSize: parameters.fontSize,
rotation: parameters.rotation,
// The UI stores opacity as a 0-100 percentage; the backend expects 0.0-1.0.
opacity: parameters.opacity / 100,
widthSpacer: parameters.widthSpacer,
heightSpacer: parameters.heightSpacer,
// The UI types alphabet as a free string; the wire always sends it (empty
// string when unset) so the value is passed through and cast to the model
// enum to preserve existing behaviour.
alphabet: (parameters.alphabet || "") as AddWatermarkApiParams["alphabet"],
customColor: parameters.customColor || "",
convertPDFToImage: parameters.convertPDFToImage ?? false,
};
if (watermarkType === "text") {
apiParams.watermarkText = parameters.watermarkText;
}
return apiParams;
};
// Reconstruct the tool's UI parameters from an add-watermark request body, so a
// stored or AI-authored step can be re-rendered in the settings UI. The
// watermark image File cannot be recovered from the request model.
export const addWatermarkFromApiParams = (
apiParams: AddWatermarkApiParams,
): Partial<AddWatermarkParameters> => {
const result: Partial<AddWatermarkParameters> = {
watermarkType: apiParams.watermarkType,
fontSize: apiParams.fontSize,
rotation: apiParams.rotation,
widthSpacer: apiParams.widthSpacer,
heightSpacer: apiParams.heightSpacer,
alphabet: apiParams.alphabet ?? defaultParameters.alphabet,
customColor: apiParams.customColor ?? defaultParameters.customColor,
convertPDFToImage:
apiParams.convertPDFToImage ?? defaultParameters.convertPDFToImage,
};
if (apiParams.opacity !== undefined) {
result.opacity = apiParams.opacity * 100;
}
if (apiParams.watermarkText !== undefined) {
result.watermarkText = apiParams.watermarkText;
}
return result;
};
// Static function that can be used by both the hook and automation executor
export const buildAddWatermarkFormData = (
parameters: AddWatermarkParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Required: watermarkType as string
formData.append("watermarkType", parameters.watermarkType || "text");
// Add watermark content based on type
if (parameters.watermarkType === "text") {
formData.append("watermarkText", parameters.watermarkText);
} else if (
parameters.watermarkType === "image" &&
parameters.watermarkImage
) {
formData.append("watermarkImage", parameters.watermarkImage);
}
// Required parameters with correct formatting (defaults merged in automationExecutor)
formData.append("fontSize", parameters.fontSize.toString());
formData.append("rotation", parameters.rotation.toString());
formData.append("opacity", (parameters.opacity / 100).toString()); // Convert percentage to decimal
formData.append("widthSpacer", parameters.widthSpacer.toString());
formData.append("heightSpacer", parameters.heightSpacer.toString());
// Backend-expected parameters from user input
formData.append("alphabet", parameters.alphabet || "");
formData.append("customColor", parameters.customColor || "");
formData.append(
"convertPDFToImage",
(parameters.convertPDFToImage ?? false).toString(),
): FormData =>
objectToFormData(
addWatermarkToApiParams(parameters),
parameters.watermarkType === "image" && parameters.watermarkImage
? { fileInput: file, watermarkImage: parameters.watermarkImage }
: { fileInput: file },
);
return formData;
};
// Static configuration object
export const addWatermarkOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddWatermarkFormData,
toApiParams: addWatermarkToApiParams,
fromApiParams: addWatermarkFromApiParams,
operationType: "watermark",
endpoint: "/api/v1/security/add-watermark",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -1,13 +1,38 @@
import { AdjustPageScaleParameters } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
import {
AdjustPageScaleParameters,
PageSize,
} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
export const ADJUST_PAGE_SCALE_ENDPOINT =
"/api/v1/general/scale-pages" satisfies ToolEndpoint;
type AdjustPageScaleApiParams =
ToolApiParams[typeof ADJUST_PAGE_SCALE_ENDPOINT];
export const adjustPageScaleToApiParams = (
parameters: AdjustPageScaleParameters,
): AdjustPageScaleApiParams => ({
scaleFactor: parameters.scaleFactor,
pageSize: parameters.pageSize,
orientation: parameters.orientation,
});
export const adjustPageScaleFromApiParams = (
apiParams: AdjustPageScaleApiParams,
): Partial<AdjustPageScaleParameters> => ({
scaleFactor: apiParams.scaleFactor,
pageSize: apiParams.pageSize as PageSize,
orientation: apiParams.orientation,
});
export const buildAdjustPageScaleFormData = (
parameters: AdjustPageScaleParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("scaleFactor", parameters.scaleFactor.toString());
formData.append("pageSize", parameters.pageSize);
formData.append("orientation", parameters.orientation);
return formData;
};
): FormData =>
objectToFormData(adjustPageScaleToApiParams(parameters), {
fileInput: file,
});
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildAdjustPageScaleFormData } from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData";
import { describe, expect, it, test } from "vitest";
import {
adjustPageScaleFromApiParams,
adjustPageScaleToApiParams,
buildAdjustPageScaleFormData,
} from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData";
import {
defaultParameters,
PageSize,
@@ -49,3 +53,20 @@ describe("buildAdjustPageScaleFormData", () => {
expect(formData.get("fileInput")).toBe(file);
});
});
describe("adjustPageScale mappers", () => {
test("round-trips backend params", () => {
const api = adjustPageScaleToApiParams({
...defaultParameters,
scaleFactor: 1.5,
pageSize: PageSize.A4,
orientation: "LANDSCAPE",
});
const roundTripped = adjustPageScaleToApiParams({
...defaultParameters,
...adjustPageScaleFromApiParams(api),
});
expect(roundTripped).toEqual(api);
});
});
@@ -8,15 +8,26 @@ import {
AdjustPageScaleParameters,
defaultParameters,
} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
import { buildAdjustPageScaleFormData } from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData";
import {
buildAdjustPageScaleFormData,
adjustPageScaleToApiParams,
adjustPageScaleFromApiParams,
ADJUST_PAGE_SCALE_ENDPOINT,
} from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData";
export { buildAdjustPageScaleFormData };
export {
buildAdjustPageScaleFormData,
adjustPageScaleToApiParams,
adjustPageScaleFromApiParams,
};
export const adjustPageScaleOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAdjustPageScaleFormData,
toApiParams: adjustPageScaleToApiParams,
fromApiParams: adjustPageScaleFromApiParams,
operationType: "scalePages",
endpoint: "/api/v1/general/scale-pages",
endpoint: ADJUST_PAGE_SCALE_ENDPOINT,
defaultParameters,
} as const;
@@ -3,40 +3,54 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AutoRenameParameters,
defaultParameters,
} from "@app/hooks/tools/autoRename/useAutoRenameParameters";
export const getFormData = (parameters: AutoRenameParameters) =>
Object.entries(parameters).map(([key, value]) => [
key,
value.toString(),
]) as string[][];
const ENDPOINT = "/api/v1/misc/auto-rename" satisfies ToolEndpoint;
type AutoRenameApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the auto-rename request body. The return
// type is the generated backend model, so a spec change that renames or drops a
// field breaks the build here.
export const autoRenameToApiParams = (
parameters: AutoRenameParameters,
): AutoRenameApiParams => ({
useFirstTextAsFallback: parameters.useFirstTextAsFallback,
});
// Reconstruct the tool's UI parameters from an auto-rename request body, so a
// stored or AI-authored step can be re-rendered in the settings UI.
export const autoRenameFromApiParams = (
apiParams: AutoRenameApiParams,
): Partial<AutoRenameParameters> => ({
useFirstTextAsFallback:
apiParams.useFirstTextAsFallback ??
defaultParameters.useFirstTextAsFallback,
});
// Static function that can be used by both the hook and automation executor
export const buildAutoRenameFormData = (
parameters: AutoRenameParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
): FormData =>
objectToFormData(autoRenameToApiParams(parameters), { fileInput: file });
// Static configuration object
export const autoRenameOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAutoRenameFormData,
toApiParams: autoRenameToApiParams,
fromApiParams: autoRenameFromApiParams,
operationType: "autoRename",
endpoint: "/api/v1/misc/auto-rename",
endpoint: ENDPOINT,
preserveBackendFilename: true, // Use filename from backend response headers
defaultParameters,
} as const;
@@ -3,36 +3,69 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
BookletImpositionParameters,
defaultParameters,
} from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters";
const ENDPOINT = "/api/v1/general/booklet-imposition" satisfies ToolEndpoint;
type BookletImpositionApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the booklet-imposition request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const bookletImpositionToApiParams = (
parameters: BookletImpositionParameters,
): BookletImpositionApiParams => ({
pagesPerSheet: parameters.pagesPerSheet,
addBorder: parameters.addBorder,
spineLocation: parameters.spineLocation,
addGutter: parameters.addGutter,
gutterSize: parameters.gutterSize,
doubleSided: parameters.doubleSided,
duplexPass: parameters.duplexPass,
flipOnShortEdge: parameters.flipOnShortEdge,
});
// Reconstruct the tool's UI parameters from a booklet-imposition request body,
// so a stored or AI-authored step can be re-rendered in the settings UI.
export const bookletImpositionFromApiParams = (
apiParams: BookletImpositionApiParams,
): Partial<BookletImpositionParameters> => ({
pagesPerSheet: apiParams.pagesPerSheet ?? defaultParameters.pagesPerSheet,
addBorder: apiParams.addBorder ?? defaultParameters.addBorder,
spineLocation: apiParams.spineLocation ?? defaultParameters.spineLocation,
addGutter: apiParams.addGutter ?? defaultParameters.addGutter,
gutterSize: apiParams.gutterSize ?? defaultParameters.gutterSize,
doubleSided: apiParams.doubleSided ?? defaultParameters.doubleSided,
duplexPass: apiParams.duplexPass ?? defaultParameters.duplexPass,
flipOnShortEdge:
apiParams.flipOnShortEdge ?? defaultParameters.flipOnShortEdge,
});
// Static configuration that can be used by both the hook and automation executor
export const buildBookletImpositionFormData = (
parameters: BookletImpositionParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("pagesPerSheet", parameters.pagesPerSheet.toString());
formData.append("addBorder", parameters.addBorder.toString());
formData.append("spineLocation", parameters.spineLocation);
formData.append("addGutter", parameters.addGutter.toString());
formData.append("gutterSize", parameters.gutterSize.toString());
formData.append("doubleSided", parameters.doubleSided.toString());
formData.append("duplexPass", parameters.duplexPass);
formData.append("flipOnShortEdge", parameters.flipOnShortEdge.toString());
return formData;
};
): FormData =>
objectToFormData(bookletImpositionToApiParams(parameters), {
fileInput: file,
});
// Static configuration object
export const bookletImpositionOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildBookletImpositionFormData,
toApiParams: bookletImpositionToApiParams,
fromApiParams: bookletImpositionFromApiParams,
operationType: "bookletImposition",
endpoint: "/api/v1/general/booklet-imposition",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -3,86 +3,146 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type FormDataFiles,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
CertSignParameters,
defaultParameters,
} from "@app/hooks/tools/certSign/useCertSignParameters";
const ENDPOINT = "/api/v1/security/cert-sign" satisfies ToolEndpoint;
type CertSignApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the cert-sign request body. The keystore
// uploads (privateKeyFile, certFile, p12File, jksFile) are actual File uploads
// and are appended separately (see buildCertSignFormData); only the scalar
// fields are serialized here.
export const certSignToApiParams = (
parameters: CertSignParameters,
): CertSignApiParams => {
// AUTO mode signs with the server certificate; no keystore/password is sent.
if (parameters.signMode === "AUTO") {
return withSignatureAppearance({ certType: "SERVER" }, parameters);
}
const apiParams: CertSignApiParams = {
certType: parameters.certType as CertSignApiParams["certType"],
password: parameters.password,
};
// Non-file identifiers depend on the chosen certificate type.
switch (parameters.certType) {
case "WINDOWS_STORE":
if (parameters.alias) apiParams.alias = parameters.alias;
break;
case "PKCS11":
if (parameters.pkcs11LibraryPath) {
apiParams.pkcs11LibraryPath = parameters.pkcs11LibraryPath;
}
if (parameters.pkcs11Slot != null) {
apiParams.pkcs11Slot = parameters.pkcs11Slot;
}
if (parameters.alias) apiParams.alias = parameters.alias;
break;
}
return withSignatureAppearance(apiParams, parameters);
};
// Signature appearance fields are only sent when the visible signature is
// enabled, matching the original form behaviour.
const withSignatureAppearance = (
apiParams: CertSignApiParams,
parameters: CertSignParameters,
): CertSignApiParams => {
if (parameters.showSignature) {
apiParams.showSignature = true;
apiParams.reason = parameters.reason;
apiParams.location = parameters.location;
apiParams.name = parameters.name;
apiParams.pageNumber = parameters.pageNumber;
apiParams.showLogo = parameters.showLogo;
}
return apiParams;
};
// Select the keystore File uploads for the chosen certificate type. AUTO mode
// (server certificate) uploads no keystore.
const certSignFiles = (parameters: CertSignParameters): FormDataFiles => {
if (parameters.signMode === "AUTO") return {};
switch (parameters.certType) {
case "PEM":
return {
privateKeyFile: parameters.privateKeyFile,
certFile: parameters.certFile,
};
case "PKCS12":
case "PFX":
return { p12File: parameters.p12File };
case "JKS":
return { jksFile: parameters.jksFile };
default:
return {};
}
};
// Reconstruct the tool's UI parameters from a cert-sign request body, so a stored
// or AI-authored step can be re-rendered in the settings UI. Uploaded keystore
// files cannot be recovered from the request model.
export const certSignFromApiParams = (
apiParams: CertSignApiParams,
): Partial<CertSignParameters> => {
const result: Partial<CertSignParameters> = {
signMode: apiParams.certType === "SERVER" ? "AUTO" : "MANUAL",
showSignature: apiParams.showSignature ?? defaultParameters.showSignature,
};
if (apiParams.certType !== "SERVER") {
result.certType = apiParams.certType;
result.password = apiParams.password ?? defaultParameters.password;
}
if (apiParams.alias !== undefined) result.alias = apiParams.alias;
if (apiParams.pkcs11LibraryPath !== undefined) {
result.pkcs11LibraryPath = apiParams.pkcs11LibraryPath;
}
if (apiParams.pkcs11Slot !== undefined) {
result.pkcs11Slot = apiParams.pkcs11Slot;
}
if (apiParams.reason !== undefined) result.reason = apiParams.reason;
if (apiParams.location !== undefined) result.location = apiParams.location;
if (apiParams.name !== undefined) result.name = apiParams.name;
if (apiParams.pageNumber !== undefined) {
result.pageNumber = apiParams.pageNumber;
}
if (apiParams.showLogo !== undefined) result.showLogo = apiParams.showLogo;
return result;
};
// Build form data for signing
export const buildCertSignFormData = (
parameters: CertSignParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Handle sign mode
if (parameters.signMode === "AUTO") {
formData.append("certType", "SERVER");
} else {
formData.append("certType", parameters.certType);
formData.append("password", parameters.password);
// Add certificate files based on type (only for manual mode)
switch (parameters.certType) {
case "PEM":
if (parameters.privateKeyFile) {
formData.append("privateKeyFile", parameters.privateKeyFile);
}
if (parameters.certFile) {
formData.append("certFile", parameters.certFile);
}
break;
case "PKCS12":
case "PFX":
if (parameters.p12File) {
formData.append("p12File", parameters.p12File);
}
break;
case "JKS":
if (parameters.jksFile) {
formData.append("jksFile", parameters.jksFile);
}
break;
case "WINDOWS_STORE":
if (parameters.alias) {
formData.append("alias", parameters.alias);
}
break;
case "PKCS11":
if (parameters.pkcs11LibraryPath) {
formData.append("pkcs11LibraryPath", parameters.pkcs11LibraryPath);
}
if (parameters.pkcs11Slot != null) {
formData.append("pkcs11Slot", parameters.pkcs11Slot.toString());
}
if (parameters.alias) {
formData.append("alias", parameters.alias);
}
break;
}
}
// Add signature appearance options if enabled
if (parameters.showSignature) {
formData.append("showSignature", "true");
formData.append("reason", parameters.reason);
formData.append("location", parameters.location);
formData.append("name", parameters.name);
formData.append("pageNumber", parameters.pageNumber.toString());
formData.append("showLogo", parameters.showLogo.toString());
}
return formData;
};
): FormData =>
objectToFormData(certSignToApiParams(parameters), {
fileInput: file,
...certSignFiles(parameters),
});
// Static configuration object
export const certSignOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCertSignFormData,
toApiParams: certSignToApiParams,
fromApiParams: certSignFromApiParams,
operationType: "certSign",
endpoint: "/api/v1/security/cert-sign",
endpoint: ENDPOINT,
multiFileEndpoint: false,
defaultParameters,
} as const;
@@ -1,7 +1,14 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useChangePermissionsOperation } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
import type { ChangePermissionsParameters } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
import {
changePermissionsFromApiParams,
changePermissionsToApiParams,
useChangePermissionsOperation,
} from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
import {
type ChangePermissionsParameters,
defaultParameters,
} from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
// Mock the useToolOperation hook
vi.mock("../shared/useToolOperation", async () => {
@@ -141,3 +148,26 @@ describe("useChangePermissionsOperation", () => {
expect(callArgs[property]).toBe(expectedValue);
});
});
describe("changePermissions mappers", () => {
test("round-trips backend params", () => {
const configured: ChangePermissionsParameters = {
preventAssembly: true,
preventExtractContent: false,
preventExtractForAccessibility: true,
preventFillInForm: false,
preventModify: true,
preventModifyAnnotations: false,
preventPrinting: true,
preventPrintingFaithful: false,
};
const api = changePermissionsToApiParams(configured);
const roundTripped = changePermissionsToApiParams({
...defaultParameters,
...changePermissionsFromApiParams(api),
});
expect(roundTripped).toEqual(api);
});
});
@@ -3,42 +3,81 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ChangePermissionsParameters,
defaultParameters,
} from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
export const getFormData = (parameters: ChangePermissionsParameters) => {
if (!parameters) return [];
return Object.entries(parameters).map(([key, value]) => [
key,
(value ?? false).toString(),
]) as string[][];
};
// Change Permissions reuses the Add Password endpoint but sends only the
// prevent* subset of the request model (no password or keyLength).
const ENDPOINT = "/api/v1/security/add-password" satisfies ToolEndpoint;
type AddPasswordApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the add-password request body. Only the
// prevent* permission flags are sent; password and keyLength are optional on the
// model and left unset, so the endpoint changes permissions without encrypting.
export const changePermissionsToApiParams = (
parameters: ChangePermissionsParameters,
): AddPasswordApiParams => ({
preventAssembly: parameters.preventAssembly ?? false,
preventExtractContent: parameters.preventExtractContent ?? false,
preventExtractForAccessibility:
parameters.preventExtractForAccessibility ?? false,
preventFillInForm: parameters.preventFillInForm ?? false,
preventModify: parameters.preventModify ?? false,
preventModifyAnnotations: parameters.preventModifyAnnotations ?? false,
preventPrinting: parameters.preventPrinting ?? false,
preventPrintingFaithful: parameters.preventPrintingFaithful ?? false,
});
// Reconstruct the tool's UI parameters from an add-password request body, so a
// stored or AI-authored step can be re-rendered in the settings UI.
export const changePermissionsFromApiParams = (
apiParams: AddPasswordApiParams,
): Partial<ChangePermissionsParameters> => ({
preventAssembly:
apiParams.preventAssembly ?? defaultParameters.preventAssembly,
preventExtractContent:
apiParams.preventExtractContent ?? defaultParameters.preventExtractContent,
preventExtractForAccessibility:
apiParams.preventExtractForAccessibility ??
defaultParameters.preventExtractForAccessibility,
preventFillInForm:
apiParams.preventFillInForm ?? defaultParameters.preventFillInForm,
preventModify: apiParams.preventModify ?? defaultParameters.preventModify,
preventModifyAnnotations:
apiParams.preventModifyAnnotations ??
defaultParameters.preventModifyAnnotations,
preventPrinting:
apiParams.preventPrinting ?? defaultParameters.preventPrinting,
preventPrintingFaithful:
apiParams.preventPrintingFaithful ??
defaultParameters.preventPrintingFaithful,
});
// Static function that can be used by both the hook and automation executor
export const buildChangePermissionsFormData = (
parameters: ChangePermissionsParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
): FormData =>
objectToFormData(changePermissionsToApiParams(parameters), {
fileInput: file,
});
return formData;
};
// Static configuration object
export const changePermissionsOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildChangePermissionsFormData,
toApiParams: changePermissionsToApiParams,
fromApiParams: changePermissionsFromApiParams,
operationType: "changePermissions",
endpoint: "/api/v1/security/add-password", // Change Permissions is a fake endpoint for the Add Password tool
endpoint: ENDPOINT, // Change Permissions is a fake endpoint for the Add Password tool
defaultParameters,
} as const;
@@ -0,0 +1,127 @@
import { describe, expect, test } from "vitest";
import {
buildCompressFormData,
compressFromApiParams,
compressToApiParams,
} from "@app/hooks/tools/compress/useCompressOperation";
import {
CompressParameters,
defaultParameters,
} from "@app/hooks/tools/compress/useCompressParameters";
const params = (
overrides: Partial<CompressParameters>,
): CompressParameters => ({
...defaultParameters,
...overrides,
});
describe("compressToApiParams", () => {
test("quality mode sends optimizeLevel and no expectedOutputSize", () => {
const api = compressToApiParams(
params({ compressionMethod: "quality", compressionLevel: 7 }),
);
expect(api.optimizeLevel).toBe(7);
expect(api.expectedOutputSize).toBeUndefined();
});
test("file-size mode sends expectedOutputSize (level still present for the spec)", () => {
const api = compressToApiParams(
params({
compressionMethod: "filesize",
fileSizeValue: "100",
fileSizeUnit: "MB",
}),
);
// optimizeLevel is required by the backend model; the backend recomputes it
// from the target size, so its presence is harmless.
expect(api.optimizeLevel).toBeDefined();
expect(api.expectedOutputSize).toBe("100MB");
});
test("omits expectedOutputSize when file-size value is empty", () => {
const api = compressToApiParams(
params({ compressionMethod: "filesize", fileSizeValue: "" }),
);
expect(api.expectedOutputSize).toBeUndefined();
});
test("line-art thresholds only included when line art is enabled", () => {
const off = compressToApiParams(params({ lineArt: false }));
expect(off.lineArtThreshold).toBeUndefined();
expect(off.lineArtEdgeLevel).toBeUndefined();
const on = compressToApiParams(
params({ lineArt: true, lineArtThreshold: 40, lineArtEdgeLevel: 2 }),
);
expect(on.lineArtThreshold).toBe(40);
expect(on.lineArtEdgeLevel).toBe(2);
});
test("defaults produce the required optimizeLevel field", () => {
const api = compressToApiParams(defaultParameters);
expect(api.optimizeLevel).toBe(defaultParameters.compressionLevel);
});
});
describe("compressFromApiParams", () => {
test("expectedOutputSize maps back to file-size mode and its value/unit", () => {
const ui = compressFromApiParams({
optimizeLevel: 5,
expectedOutputSize: "25KB",
});
expect(ui.compressionMethod).toBe("filesize");
expect(ui.fileSizeValue).toBe("25");
expect(ui.fileSizeUnit).toBe("KB");
});
test("no expectedOutputSize maps back to quality mode", () => {
const ui = compressFromApiParams({ optimizeLevel: 8 });
expect(ui.compressionMethod).toBe("quality");
expect(ui.compressionLevel).toBe(8);
});
});
describe("compress round-trip", () => {
test.each<Partial<CompressParameters>>([
{ compressionMethod: "quality", compressionLevel: 3, grayscale: true },
{
compressionMethod: "filesize",
fileSizeValue: "10",
fileSizeUnit: "MB",
linearize: true,
},
{
compressionMethod: "quality",
lineArt: true,
lineArtThreshold: 60,
lineArtEdgeLevel: 3,
},
])("toApiParams(fromApiParams(x)) reproduces x %o", (overrides) => {
const api = compressToApiParams(params(overrides));
const roundTripped = compressToApiParams(
params(compressFromApiParams(api)),
);
expect(roundTripped).toEqual(api);
});
});
describe("buildCompressFormData", () => {
test("appends the file and serialized parameters", () => {
const file = new File(["x"], "test.pdf", { type: "application/pdf" });
const formData = buildCompressFormData(
params({ compressionMethod: "quality", compressionLevel: 6 }),
file,
);
expect(formData.get("fileInput")).toBe(file);
expect(formData.get("optimizeLevel")).toBe("6");
expect(formData.get("grayscale")).toBe("false");
});
});
@@ -3,48 +3,100 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
CompressParameters,
defaultParameters,
} from "@app/hooks/tools/compress/useCompressParameters";
const ENDPOINT = "/api/v1/misc/compress-pdf" satisfies ToolEndpoint;
type CompressApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the compress-pdf request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const compressToApiParams = (
parameters: CompressParameters,
): CompressApiParams => {
const apiParams: CompressApiParams = {
// compressionLevel is validated to 1-9 by the parameters hook. It is always
// sent: in file-size mode the backend recomputes the level from the target
// size (autoMode in CompressController), so this value only takes effect in
// quality mode.
optimizeLevel:
parameters.compressionLevel as CompressApiParams["optimizeLevel"],
grayscale: parameters.grayscale ?? false,
lineArt: parameters.lineArt,
linearize: parameters.linearize,
};
if (parameters.compressionMethod === "filesize" && parameters.fileSizeValue) {
apiParams.expectedOutputSize = `${parameters.fileSizeValue}${parameters.fileSizeUnit}`;
}
if (parameters.lineArt) {
apiParams.lineArtThreshold = parameters.lineArtThreshold;
apiParams.lineArtEdgeLevel = parameters.lineArtEdgeLevel;
}
return apiParams;
};
// Reconstruct the tool's UI parameters from a compress-pdf request body, so a
// stored or AI-authored step can be re-rendered in the settings UI.
export const compressFromApiParams = (
apiParams: CompressApiParams,
): Partial<CompressParameters> => {
const result: Partial<CompressParameters> = {
compressionLevel: apiParams.optimizeLevel,
grayscale: apiParams.grayscale ?? defaultParameters.grayscale,
lineArt: apiParams.lineArt ?? defaultParameters.lineArt,
linearize: apiParams.linearize ?? defaultParameters.linearize,
};
if (apiParams.lineArtThreshold !== undefined) {
result.lineArtThreshold = apiParams.lineArtThreshold;
}
if (apiParams.lineArtEdgeLevel !== undefined) {
result.lineArtEdgeLevel = apiParams.lineArtEdgeLevel;
}
if (apiParams.expectedOutputSize) {
result.compressionMethod = "filesize";
const match = /^(\d+(?:\.\d+)?)(KB|MB)$/i.exec(
apiParams.expectedOutputSize,
);
if (match) {
result.fileSizeValue = match[1];
result.fileSizeUnit = match[2].toUpperCase() as "KB" | "MB";
}
} else {
result.compressionMethod = "quality";
}
return result;
};
// Static configuration that can be used by both the hook and automation executor
export const buildCompressFormData = (
parameters: CompressParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (parameters.compressionMethod === "quality") {
formData.append("optimizeLevel", parameters.compressionLevel.toString());
} else {
// File size method
const fileSize = parameters.fileSizeValue
? `${parameters.fileSizeValue}${parameters.fileSizeUnit}`
: "";
if (fileSize) {
formData.append("expectedOutputSize", fileSize);
}
}
formData.append("grayscale", (parameters.grayscale ?? false).toString());
formData.append("lineArt", parameters.lineArt.toString());
formData.append("linearize", parameters.linearize.toString());
if (parameters.lineArt) {
formData.append("lineArtThreshold", parameters.lineArtThreshold.toString());
formData.append("lineArtEdgeLevel", parameters.lineArtEdgeLevel.toString());
}
return formData;
};
): FormData =>
objectToFormData(compressToApiParams(parameters), { fileInput: file });
// Static configuration object
export const compressOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCompressFormData,
toApiParams: compressToApiParams,
fromApiParams: compressFromApiParams,
operationType: "compress",
endpoint: "/api/v1/misc/compress-pdf",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useCompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
describe("useCompressParameters", () => {
test("defaults (quality mode) validate", () => {
const { result } = renderHook(() => useCompressParameters());
expect(result.current.validateParameters()).toBe(true);
});
test("compressionLevel outside 1-9 is invalid", () => {
const { result } = renderHook(() => useCompressParameters());
act(() => {
result.current.updateParameter("compressionLevel", 0);
});
expect(result.current.validateParameters()).toBe(false);
act(() => {
result.current.updateParameter("compressionLevel", 10);
});
expect(result.current.validateParameters()).toBe(false);
});
test("filesize mode requires a target size", () => {
const { result } = renderHook(() => useCompressParameters());
// Filesize mode with no size entered must not validate: otherwise the
// request omits expectedOutputSize and the backend silently falls back to a
// quality compression.
act(() => {
result.current.updateParameter("compressionMethod", "filesize");
});
expect(result.current.validateParameters()).toBe(false);
act(() => {
result.current.updateParameter("fileSizeValue", "5");
});
expect(result.current.validateParameters()).toBe(true);
});
});
@@ -37,8 +37,15 @@ export const useCompressParameters = (): CompressParametersHook => {
defaultParameters,
endpointName: "compress-pdf",
validateFn: (params) => {
// For compression, we only need to validate that compression level is within range
return params.compressionLevel >= 1 && params.compressionLevel <= 9;
if (params.compressionLevel < 1 || params.compressionLevel > 9) {
return false;
}
// Filesize mode needs a target size; without one the request omits
// expectedOutputSize and the backend silently does a quality compression.
if (params.compressionMethod === "filesize") {
return params.fileSizeValue.trim() !== "";
}
return true;
},
});
};
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import {
cropFromApiParams,
cropToApiParams,
} from "@app/hooks/tools/crop/useCropOperation";
import {
CropParameters,
defaultParameters,
} from "@app/hooks/tools/crop/useCropParameters";
describe("crop mappers", () => {
// With autoCrop on the coordinates aren't sent, so they must not resurface on
// the round trip; with autoCrop off the rectangle must survive intact.
test.each<{ label: string; overrides: Partial<CropParameters> }>([
{ label: "autoCrop on", overrides: { autoCrop: true } },
{
label: "autoCrop off with a rectangle",
overrides: {
autoCrop: false,
cropArea: { x: 10, y: 20, width: 300, height: 400 },
},
},
])("round-trips backend params ($label)", ({ overrides }) => {
const api = cropToApiParams({ ...defaultParameters, ...overrides });
const roundTripped = cropToApiParams({
...defaultParameters,
...cropFromApiParams(api),
});
expect(roundTripped).toEqual(api);
});
});
@@ -3,40 +3,69 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
CropParameters,
defaultParameters,
} from "@app/hooks/tools/crop/useCropParameters";
import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
const ENDPOINT = "/api/v1/general/crop" satisfies ToolEndpoint;
type CropApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the crop request body. The return type
// is the generated backend model, so a spec change that renames or drops a
// field breaks the build here.
export const cropToApiParams = (parameters: CropParameters): CropApiParams => {
const apiParams: CropApiParams = {
autoCrop: parameters.autoCrop,
};
if (!parameters.autoCrop) {
const cropArea = parameters.cropArea;
apiParams.x = cropArea.x;
apiParams.y = cropArea.y;
apiParams.width = cropArea.width;
apiParams.height = cropArea.height;
}
return apiParams;
};
// Reconstruct the tool's UI parameters from a crop request body, so a stored or
// AI-authored step can be re-rendered in the settings UI.
export const cropFromApiParams = (
apiParams: CropApiParams,
): Partial<CropParameters> => ({
autoCrop: apiParams.autoCrop ?? defaultParameters.autoCrop,
cropArea: {
x: apiParams.x ?? DEFAULT_CROP_AREA.x,
y: apiParams.y ?? DEFAULT_CROP_AREA.y,
width: apiParams.width ?? DEFAULT_CROP_AREA.width,
height: apiParams.height ?? DEFAULT_CROP_AREA.height,
},
});
// Static configuration that can be used by both the hook and automation executor
export const buildCropFormData = (
parameters: CropParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (!parameters.autoCrop) {
const cropArea = parameters.cropArea;
formData.append("x", cropArea.x.toString());
formData.append("y", cropArea.y.toString());
formData.append("width", cropArea.width.toString());
formData.append("height", cropArea.height.toString());
}
formData.append("autoCrop", parameters.autoCrop.toString());
return formData;
};
): FormData =>
objectToFormData(cropToApiParams(parameters), { fileInput: file });
// Static configuration object
export const cropOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCropFormData,
toApiParams: cropToApiParams,
fromApiParams: cropFromApiParams,
operationType: "crop",
endpoint: "/api/v1/general/crop",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -0,0 +1,46 @@
import { describe, test, expect } from "vitest";
import { expectConsole } from "@app/tests/failOnConsole";
import { editTableOfContentsFromApiParams } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation";
describe("editTableOfContentsFromApiParams", () => {
test("passes replaceExisting through", () => {
expect(editTableOfContentsFromApiParams({ replaceExisting: true })).toEqual(
{
replaceExisting: true,
},
);
});
test("hydrates a valid (empty) bookmark array", () => {
expect(
editTableOfContentsFromApiParams({
replaceExisting: false,
bookmarkData: "[]",
}),
).toEqual({ replaceExisting: false, bookmarks: [] });
});
test.each(["", "not json", "{truncated"])(
"does not throw on malformed bookmarkData (%j); leaves bookmarks unset",
(bookmarkData) => {
expectConsole.warn(/could not parse bookmarkData/);
const result = editTableOfContentsFromApiParams({
replaceExisting: true,
bookmarkData,
});
expect(result).toEqual({ replaceExisting: true });
expect(result).not.toHaveProperty("bookmarks");
},
);
test.each(["{}", "null", "42"])(
"ignores non-array bookmarkData (%j) without throwing",
(bookmarkData) => {
const result = editTableOfContentsFromApiParams({
replaceExisting: false,
bookmarkData,
});
expect(result).not.toHaveProperty("bookmarks");
},
);
});
@@ -4,30 +4,74 @@ import {
type ToolOperationConfig,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { EditTableOfContentsParameters } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters";
import { serializeBookmarkNodes } from "@app/utils/editTableOfContents";
import {
hydrateBookmarkPayload,
serializeBookmarkNodes,
type BookmarkPayload,
} from "@app/utils/editTableOfContents";
const ENDPOINT =
"/api/v1/general/edit-table-of-contents" satisfies ToolEndpoint;
type EditTableOfContentsApiParams = ToolApiParams[typeof ENDPOINT];
// bookmarkData is a string in the backend model even though it carries JSON, so
// the serialized bookmark tree is JSON-encoded into that string here.
export const editTableOfContentsToApiParams = (
parameters: EditTableOfContentsParameters,
): EditTableOfContentsApiParams => ({
replaceExisting: parameters.replaceExisting,
bookmarkData: JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)),
});
export const editTableOfContentsFromApiParams = (
apiParams: EditTableOfContentsApiParams,
): Partial<EditTableOfContentsParameters> => {
const result: Partial<EditTableOfContentsParameters> = {
replaceExisting: apiParams.replaceExisting,
};
// bookmarkData carries JSON in a string field, so a stored step
// could hold malformed or non-array content. Degrade to leaving bookmarks unset.
if (apiParams.bookmarkData !== undefined) {
try {
const payload = JSON.parse(apiParams.bookmarkData) as BookmarkPayload[];
if (Array.isArray(payload)) {
result.bookmarks = hydrateBookmarkPayload(payload);
}
} catch (error) {
console.warn(
`editTableOfContents: could not parse bookmarkData; ` +
`leaving bookmarks unset. Error: ${error}`,
);
}
}
return result;
};
const buildFormData = (
parameters: EditTableOfContentsParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("replaceExisting", String(parameters.replaceExisting));
formData.append(
"bookmarkData",
JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)),
);
return formData;
};
): FormData =>
objectToFormData(editTableOfContentsToApiParams(parameters), {
fileInput: file,
});
export const editTableOfContentsOperationConfig: ToolOperationConfig<EditTableOfContentsParameters> =
{
toolType: ToolType.singleFile,
operationType: "editTableOfContents",
endpoint: "/api/v1/general/edit-table-of-contents",
endpoint: ENDPOINT,
buildFormData,
toApiParams: editTableOfContentsToApiParams,
fromApiParams: editTableOfContentsFromApiParams,
};
export const useEditTableOfContentsOperation = () => {
@@ -4,6 +4,11 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ExtractImagesParameters,
@@ -11,24 +16,38 @@ import {
} from "@app/hooks/tools/extractImages/useExtractImagesParameters";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
const ENDPOINT = "/api/v1/misc/extract-images" satisfies ToolEndpoint;
type ExtractImagesApiParams = ToolApiParams[typeof ENDPOINT];
// The frontend param type uses "jpg" while the backend model uses "jpeg"; the
// wire value is preserved verbatim (as the pre-mapper code did) via the cast.
export const extractImagesToApiParams = (
parameters: ExtractImagesParameters,
): ExtractImagesApiParams => ({
format: parameters.format as ExtractImagesApiParams["format"],
});
export const extractImagesFromApiParams = (
apiParams: ExtractImagesApiParams,
): Partial<ExtractImagesParameters> => ({
format: apiParams.format as ExtractImagesParameters["format"],
});
// Static configuration that can be used by both the hook and automation executor
export const buildExtractImagesFormData = (
parameters: ExtractImagesParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("format", parameters.format);
// formData.append("allowDuplicates", parameters.allowDuplicates.toString());
return formData;
};
): FormData =>
objectToFormData(extractImagesToApiParams(parameters), { fileInput: file });
// Static configuration object (without response handler - will be added in hook)
export const extractImagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildExtractImagesFormData,
toApiParams: extractImagesToApiParams,
fromApiParams: extractImagesFromApiParams,
operationType: "extractImages",
endpoint: "/api/v1/misc/extract-images",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -3,32 +3,69 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
FlattenParameters,
defaultParameters,
} from "@app/hooks/tools/flatten/useFlattenParameters";
const ENDPOINT = "/api/v1/misc/flatten" satisfies ToolEndpoint;
type FlattenApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the flatten request body. The return
// type is the generated backend model, so a spec change that renames or drops a
// field breaks the build here.
export const flattenToApiParams = (
parameters: FlattenParameters,
): FlattenApiParams => {
const apiParams: FlattenApiParams = {
flattenOnlyForms: parameters.flattenOnlyForms,
};
if (parameters.renderDpi != null) {
apiParams.renderDpi = parameters.renderDpi;
}
return apiParams;
};
// Reconstruct the tool's UI parameters from a flatten request body, so a stored
// or AI-authored step can be re-rendered in the settings UI.
export const flattenFromApiParams = (
apiParams: FlattenApiParams,
): Partial<FlattenParameters> => {
const result: Partial<FlattenParameters> = {
flattenOnlyForms:
apiParams.flattenOnlyForms ?? defaultParameters.flattenOnlyForms,
};
if (apiParams.renderDpi != null) {
result.renderDpi = apiParams.renderDpi;
}
return result;
};
// Static function that can be used by both the hook and automation executor
export const buildFlattenFormData = (
parameters: FlattenParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("flattenOnlyForms", parameters.flattenOnlyForms.toString());
if (parameters.renderDpi != null) {
formData.append("renderDpi", parameters.renderDpi.toString());
}
return formData;
};
): FormData =>
objectToFormData(flattenToApiParams(parameters), { fileInput: file });
// Static configuration object
export const flattenOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildFlattenFormData,
toApiParams: flattenToApiParams,
fromApiParams: flattenFromApiParams,
operationType: "flatten",
endpoint: "/api/v1/misc/flatten",
endpoint: ENDPOINT,
multiFileEndpoint: false,
defaultParameters,
} as const;
@@ -29,6 +29,10 @@ import {
ToolOperationHook,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
mergeFromApiParams,
mergeToApiParams,
} from "@app/hooks/tools/merge/useMergeOperation";
describe("useMergeOperation", () => {
const mockUseToolOperation = vi.mocked(useToolOperation<MergeParameters>);
@@ -128,4 +132,60 @@ describe("useMergeOperation", () => {
expect(formData2.get("removeCertSign")).toBe("true");
expect(formData2.get("generateToc")).toBe("true");
});
test("should include client file IDs derived from the files", () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockFiles = [
new File(["a"], "a.pdf", { type: "application/pdf" }),
new File(["b"], "b.pdf", { type: "application/pdf" }),
];
const formData = config.buildFormData(
{ removeDigitalSignature: false, generateTableOfContents: false },
mockFiles,
);
expect(formData.get("clientFileIds")).toBe(
JSON.stringify(["a.pdf", "b.pdf"]),
);
});
});
describe("merge mappers", () => {
test("toApiParams renames UI fields to the backend request model", () => {
expect(
mergeToApiParams({
removeDigitalSignature: true,
generateTableOfContents: false,
}),
).toEqual({
sortType: "orderProvided",
removeCertSign: true,
generateToc: false,
});
});
test("fromApiParams maps the backend request model back to UI fields", () => {
expect(
mergeFromApiParams({ removeCertSign: false, generateToc: true }),
).toEqual({
removeDigitalSignature: false,
generateTableOfContents: true,
});
});
test("round-trips backend params", () => {
const api = mergeToApiParams({
removeDigitalSignature: true,
generateTableOfContents: true,
});
const roundTripped = mergeToApiParams({
removeDigitalSignature: false,
generateTableOfContents: false,
...mergeFromApiParams(api),
});
expect(roundTripped).toEqual(api);
});
});
@@ -4,36 +4,54 @@ import {
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
MergeParameters,
defaultParameters,
} from "@app/hooks/tools/merge/useMergeParameters";
const ENDPOINT = "/api/v1/general/merge-pdfs" satisfies ToolEndpoint;
type MergeApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the merge-pdfs request body. File-derived
// fields (clientFileIds) are appended by buildFormData, not here.
export const mergeToApiParams = (
parameters: MergeParameters,
): MergeApiParams => ({
// The UI owns file ordering, so the backend is always told to keep it.
sortType: "orderProvided",
removeCertSign: parameters.removeDigitalSignature ?? false,
generateToc: parameters.generateTableOfContents ?? false,
});
// Reconstruct the tool's UI parameters from a merge-pdfs request body.
export const mergeFromApiParams = (
apiParams: MergeApiParams,
): Partial<MergeParameters> => ({
removeDigitalSignature:
apiParams.removeCertSign ?? defaultParameters.removeDigitalSignature,
generateTableOfContents:
apiParams.generateToc ?? defaultParameters.generateTableOfContents,
});
const buildFormData = (
parameters: MergeParameters,
files: File[],
): FormData => {
const formData = new FormData();
files.forEach((file) => {
formData.append("fileInput", file);
const formData = objectToFormData(mergeToApiParams(parameters), {
fileInput: files,
});
// Provide stable client file IDs (align with files order)
// Stable client file IDs, aligned with the fileInput order. Derived from the
// files themselves, so it belongs to the file-appending step.
const clientIds: string[] = files.map((f) =>
String((f as { fileId?: string }).fileId || f.name),
);
formData.append("clientFileIds", JSON.stringify(clientIds));
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
formData.append(
"removeCertSign",
(parameters.removeDigitalSignature ?? false).toString(),
);
formData.append(
"generateToc",
(parameters.generateTableOfContents ?? false).toString(),
);
return formData;
};
@@ -41,8 +59,10 @@ const buildFormData = (
export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
toolType: ToolType.multiFile,
buildFormData,
toApiParams: mergeToApiParams,
fromApiParams: mergeFromApiParams,
operationType: "merge",
endpoint: "/api/v1/general/merge-pdfs",
endpoint: ENDPOINT,
filePrefix: "merged_",
defaultParameters,
};
@@ -9,9 +9,17 @@ import {
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
const ENDPOINT = "/api/v1/misc/ocr-pdf" satisfies ToolEndpoint;
type OCRApiParams = ToolApiParams[typeof ENDPOINT];
// Helper: get MIME type based on file extension
function getMimeType(filename: string): string {
const ext = filename.toLowerCase().split(".").pop();
@@ -48,28 +56,49 @@ function stripExt(name: string): string {
return i > 0 ? name.slice(0, i) : name;
}
// Convert the tool's UI parameters into the ocr-pdf request body. The return
// type is the generated backend model, so a spec change that renames or drops a
// field breaks the build here.
export const ocrToApiParams = (parameters: OCRParameters): OCRApiParams => {
const options = parameters.additionalOptions || [];
return {
languages: parameters.languages,
ocrType: parameters.ocrType as OCRApiParams["ocrType"],
ocrRenderType: parameters.ocrRenderType as OCRApiParams["ocrRenderType"],
sidecar: options.includes("sidecar"),
deskew: options.includes("deskew"),
clean: options.includes("clean"),
cleanFinal: options.includes("cleanFinal"),
removeImagesAfter: options.includes("removeImagesAfter"),
};
};
// Reconstruct the tool's UI parameters from an ocr-pdf request body, so a stored
// or AI-authored step can be re-rendered in the settings UI.
export const ocrFromApiParams = (
apiParams: OCRApiParams,
): Partial<OCRParameters> => {
const additionalOptions: string[] = [];
if (apiParams.sidecar) additionalOptions.push("sidecar");
if (apiParams.deskew) additionalOptions.push("deskew");
if (apiParams.clean) additionalOptions.push("clean");
if (apiParams.cleanFinal) additionalOptions.push("cleanFinal");
if (apiParams.removeImagesAfter) additionalOptions.push("removeImagesAfter");
return {
languages: apiParams.languages ?? defaultParameters.languages,
ocrType: apiParams.ocrType,
ocrRenderType: apiParams.ocrRenderType ?? defaultParameters.ocrRenderType,
additionalOptions,
};
};
// Static function that can be used by both the hook and automation executor
export const buildOCRFormData = (
parameters: OCRParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
parameters.languages.forEach((lang) => formData.append("languages", lang));
formData.append("ocrType", parameters.ocrType);
formData.append("ocrRenderType", parameters.ocrRenderType);
const options = parameters.additionalOptions || [];
formData.append("sidecar", options.includes("sidecar").toString());
formData.append("deskew", options.includes("deskew").toString());
formData.append("clean", options.includes("clean").toString());
formData.append("cleanFinal", options.includes("cleanFinal").toString());
formData.append(
"removeImagesAfter",
options.includes("removeImagesAfter").toString(),
);
return formData;
};
): FormData =>
objectToFormData(ocrToApiParams(parameters), { fileInput: file });
// Static response handler for OCR - can be used by automation executor
export const ocrResponseHandler = async (
@@ -125,8 +154,10 @@ export const ocrResponseHandler = async (
export const ocrOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildOCRFormData,
toApiParams: ocrToApiParams,
fromApiParams: ocrFromApiParams,
operationType: "ocr",
endpoint: "/api/v1/misc/ocr-pdf",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -4,41 +4,69 @@ import {
ToolType,
type ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { type OverlayPdfsParameters } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
import {
type OverlayPdfsParameters,
defaultParameters,
} from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
const ENDPOINT = "/api/v1/general/overlay-pdfs" satisfies ToolEndpoint;
type OverlayPdfsApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the overlay-pdfs request body. The
// overlay documents are actual File uploads sent as repeated `overlayFiles`
// fields (see buildFormData), so `overlayFiles` here is an empty array: the real
// uploads are appended separately and an empty array serializes to no fields.
export const overlayPdfsToApiParams = (
parameters: OverlayPdfsParameters,
): OverlayPdfsApiParams => {
const apiParams: OverlayPdfsApiParams = {
overlayFiles: [],
overlayMode: parameters.overlayMode,
overlayPosition: parameters.overlayPosition,
};
// Counts are only relevant for FixedRepeatOverlay; the server accepts repeated
// 'counts' fields.
if (parameters.overlayMode === "FixedRepeatOverlay") {
apiParams.counts = parameters.counts || [];
}
return apiParams;
};
// Reconstruct the tool's UI parameters from an overlay-pdfs request body. The
// overlay File uploads cannot be recovered from the request model.
export const overlayPdfsFromApiParams = (
apiParams: OverlayPdfsApiParams,
): Partial<OverlayPdfsParameters> => ({
overlayMode: apiParams.overlayMode,
overlayPosition: apiParams.overlayPosition,
counts: apiParams.counts ?? defaultParameters.counts,
});
const buildFormData = (
parameters: OverlayPdfsParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Overlay files
for (const overlay of parameters.overlayFiles || []) {
formData.append("overlayFiles", overlay);
}
// Mode and position
formData.append("overlayMode", parameters.overlayMode);
formData.append("overlayPosition", String(parameters.overlayPosition));
// Counts (only relevant for FixedRepeatOverlay, server accepts repeated 'counts' fields)
if (parameters.overlayMode === "FixedRepeatOverlay") {
for (const count of parameters.counts || []) {
formData.append("counts", String(count));
}
}
return formData;
};
): FormData =>
objectToFormData(overlayPdfsToApiParams(parameters), {
fileInput: file,
overlayFiles: parameters.overlayFiles || [],
});
export const overlayPdfsOperationConfig: ToolOperationConfig<OverlayPdfsParameters> =
{
toolType: ToolType.singleFile,
buildFormData,
toApiParams: overlayPdfsToApiParams,
fromApiParams: overlayPdfsFromApiParams,
operationType: "overlayPdfs",
endpoint: "/api/v1/general/overlay-pdfs",
endpoint: ENDPOINT,
};
export const useOverlayPdfsOperation = () => {
@@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import {
pageLayoutFromApiParams,
pageLayoutToApiParams,
} from "@app/hooks/tools/pageLayout/usePageLayoutOperation";
import {
PageLayoutParameters,
defaultParameters,
} from "@app/hooks/tools/pageLayout/usePageLayoutParameters";
describe("pageLayout mappers", () => {
test.each<Partial<PageLayoutParameters>>([
{},
{ addBorder: true, borderWidth: 3, innerMargin: 5, topMargin: 2 },
])("round-trips backend params for %o", (overrides) => {
const api = pageLayoutToApiParams({ ...defaultParameters, ...overrides });
const roundTripped = pageLayoutToApiParams({
...defaultParameters,
...pageLayoutFromApiParams(api),
});
expect(roundTripped).toEqual(api);
});
});
@@ -3,40 +3,77 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
PageLayoutParameters,
defaultParameters,
} from "@app/hooks/tools/pageLayout/usePageLayoutParameters";
const ENDPOINT = "/api/v1/general/multi-page-layout" satisfies ToolEndpoint;
type PageLayoutApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the multi-page-layout request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const pageLayoutToApiParams = (
parameters: PageLayoutParameters,
): PageLayoutApiParams => ({
mode: parameters.mode,
pagesPerSheet:
parameters.pagesPerSheet as PageLayoutApiParams["pagesPerSheet"],
rows: parameters.rows,
cols: parameters.cols,
orientation: parameters.orientation,
arrangement: parameters.arrangement,
readingDirection: parameters.readingDirection,
innerMargin: parameters.innerMargin ?? 0,
topMargin: parameters.topMargin ?? 0,
bottomMargin: parameters.bottomMargin ?? 0,
leftMargin: parameters.leftMargin ?? 0,
rightMargin: parameters.rightMargin ?? 0,
addBorder: parameters.addBorder,
borderWidth: parameters.borderWidth ?? 1,
});
// Reconstruct the tool's UI parameters from a multi-page-layout request body, so
// a stored or AI-authored step can be re-rendered in the settings UI.
export const pageLayoutFromApiParams = (
apiParams: PageLayoutApiParams,
): Partial<PageLayoutParameters> => ({
mode: apiParams.mode,
pagesPerSheet: apiParams.pagesPerSheet,
rows: apiParams.rows,
cols: apiParams.cols,
orientation: apiParams.orientation,
arrangement: apiParams.arrangement,
readingDirection: apiParams.readingDirection,
innerMargin: apiParams.innerMargin,
topMargin: apiParams.topMargin,
bottomMargin: apiParams.bottomMargin,
leftMargin: apiParams.leftMargin,
rightMargin: apiParams.rightMargin,
addBorder: apiParams.addBorder,
borderWidth: apiParams.borderWidth,
});
export const buildPageLayoutFormData = (
parameters: PageLayoutParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("mode", String(parameters.mode));
formData.append("pagesPerSheet", String(parameters.pagesPerSheet));
formData.append("rows", String(parameters.rows));
formData.append("cols", String(parameters.cols));
formData.append("orientation", String(parameters.orientation));
formData.append("arrangement", String(parameters.arrangement));
formData.append("readingDirection", String(parameters.readingDirection));
formData.append("innerMargin", String(parameters.innerMargin ?? 0));
formData.append("topMargin", String(parameters.topMargin ?? 0));
formData.append("bottomMargin", String(parameters.bottomMargin ?? 0));
formData.append("leftMargin", String(parameters.leftMargin ?? 0));
formData.append("rightMargin", String(parameters.rightMargin ?? 0));
formData.append("addBorder", String(parameters.addBorder));
formData.append("borderWidth", String(parameters.borderWidth ?? 1));
return formData;
};
): FormData =>
objectToFormData(pageLayoutToApiParams(parameters), { fileInput: file });
export const pageLayoutOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildPageLayoutFormData,
toApiParams: pageLayoutToApiParams,
fromApiParams: pageLayoutFromApiParams,
operationType: "pageLayout",
endpoint: "/api/v1/general/multi-page-layout",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -3,52 +3,75 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RedactParameters,
defaultParameters,
} from "@app/hooks/tools/redact/useRedactParameters";
// Automatic redaction is the only mode that calls the backend; manual redaction
// is handled client-side by EmbedPDF in the viewer.
const AUTO_ENDPOINT = "/api/v1/security/auto-redact" satisfies ToolEndpoint;
type RedactApiParams = ToolApiParams[typeof AUTO_ENDPOINT];
// Convert the tool's UI parameters into the auto-redact request body.
export const redactToApiParams = (
parameters: RedactParameters,
): RedactApiParams => ({
// The backend takes the search terms as a single newline-separated string.
listOfText: parameters.wordsToRedact.join("\n"),
useRegex: parameters.useRegex,
wholeWordSearch: parameters.wholeWordSearch,
// The backend expects the hex colour without the leading '#'.
redactColor: parameters.redactColor.replace("#", ""),
customPadding: parameters.customPadding,
convertPDFToImage: parameters.convertPDFToImage,
});
// Reconstruct the tool's UI parameters from an auto-redact request body.
export const redactFromApiParams = (
apiParams: RedactApiParams,
): Partial<RedactParameters> => ({
mode: "automatic",
wordsToRedact: apiParams.listOfText ? apiParams.listOfText.split("\n") : [],
useRegex: apiParams.useRegex ?? defaultParameters.useRegex,
wholeWordSearch:
apiParams.wholeWordSearch ?? defaultParameters.wholeWordSearch,
redactColor: apiParams.redactColor
? `#${apiParams.redactColor}`
: defaultParameters.redactColor,
customPadding: apiParams.customPadding,
convertPDFToImage:
apiParams.convertPDFToImage ?? defaultParameters.convertPDFToImage,
});
// Static configuration that can be used by both the hook and automation executor
export const buildRedactFormData = (
parameters: RedactParameters,
file: File,
): FormData => {
const formData = new FormData();
// For automatic mode we hit the backend and need full payload
if (parameters.mode === "automatic") {
formData.append("fileInput", file);
// Convert array to newline-separated string as expected by backend
formData.append("listOfText", parameters.wordsToRedact.join("\n"));
formData.append("useRegex", parameters.useRegex.toString());
formData.append("wholeWordSearch", parameters.wholeWordSearch.toString());
formData.append("redactColor", parameters.redactColor.replace("#", ""));
formData.append("customPadding", parameters.customPadding.toString());
formData.append(
"convertPDFToImage",
parameters.convertPDFToImage.toString(),
);
} else {
// Manual redaction uses EmbedPDF in-viewer; we don't call the API.
// Return an empty formData to satisfy shared interfaces without throwing.
// Manual redaction uses EmbedPDF in-viewer and makes no API call; return an
// empty payload to satisfy the shared interface without throwing.
if (parameters.mode !== "automatic") {
return new FormData();
}
return formData;
return objectToFormData(redactToApiParams(parameters), { fileInput: file });
};
// Static configuration object
export const redactOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRedactFormData,
toApiParams: redactToApiParams,
fromApiParams: redactFromApiParams,
operationType: "redact",
endpoint: (parameters: RedactParameters) => {
if (parameters.mode === "automatic") {
return "/api/v1/security/auto-redact";
}
// Manual redaction is handled by EmbedPDF in the viewer; no endpoint call.
return "";
},
endpoint: (parameters: RedactParameters) =>
parameters.mode === "automatic" ? AUTO_ENDPOINT : null,
defaultParameters,
} as const;
@@ -5,6 +5,11 @@ import {
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemoveBlanksParameters,
@@ -12,23 +17,37 @@ import {
} from "@app/hooks/tools/removeBlanks/useRemoveBlanksParameters";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
const ENDPOINT = "/api/v1/misc/remove-blanks" satisfies ToolEndpoint;
type RemoveBlanksApiParams = ToolApiParams[typeof ENDPOINT];
// Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP
export const removeBlanksToApiParams = (
parameters: RemoveBlanksParameters,
): RemoveBlanksApiParams => ({
threshold: parameters.threshold,
whitePercent: parameters.whitePercent,
});
export const removeBlanksFromApiParams = (
apiParams: RemoveBlanksApiParams,
): Partial<RemoveBlanksParameters> => ({
threshold: apiParams.threshold,
whitePercent: apiParams.whitePercent,
});
export const buildRemoveBlanksFormData = (
parameters: RemoveBlanksParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("threshold", String(parameters.threshold));
formData.append("whitePercent", String(parameters.whitePercent));
// Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP
return formData;
};
): FormData =>
objectToFormData(removeBlanksToApiParams(parameters), { fileInput: file });
export const removeBlanksOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveBlanksFormData,
toApiParams: removeBlanksToApiParams,
fromApiParams: removeBlanksFromApiParams,
operationType: "removeBlanks",
endpoint: "/api/v1/misc/remove-blanks",
endpoint: ENDPOINT,
defaultParameters,
} as const satisfies ToolOperationConfig<RemoveBlanksParameters>;
@@ -3,28 +3,35 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
fileOnlyMapping,
objectToFormData,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemoveCertificateSignParameters,
defaultParameters,
} from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters";
// Static function that can be used by both the hook and automation executor
const ENDPOINT = "/api/v1/security/remove-cert-sign" satisfies ToolEndpoint;
// Removing certificate signatures takes only a file; no parameters to map.
const { toApiParams, fromApiParams } = fileOnlyMapping();
export const buildRemoveCertificateSignFormData = (
_parameters: RemoveCertificateSignParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
export const removeCertificateSignOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveCertificateSignFormData,
toApiParams,
fromApiParams,
operationType: "removeCertSign",
endpoint: "/api/v1/security/remove-cert-sign",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -4,24 +4,32 @@ import {
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
fileOnlyMapping,
objectToFormData,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import type { RemoveImageParameters } from "@app/hooks/tools/removeImage/useRemoveImageParameters";
const ENDPOINT = "/api/v1/general/remove-image-pdf" satisfies ToolEndpoint;
// Remove-image takes only a file; there are no request parameters to map.
const { toApiParams, fromApiParams } = fileOnlyMapping();
export const buildRemoveImageFormData = (
_params: RemoveImageParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
): FormData => objectToFormData(toApiParams(), { fileInput: file });
export const removeImageOperationConfig: ToolOperationConfig<RemoveImageParameters> =
{
toolType: ToolType.singleFile,
buildFormData: buildRemoveImageFormData,
toApiParams,
fromApiParams,
operationType: "removeImage",
endpoint: "/api/v1/general/remove-image-pdf",
endpoint: ENDPOINT,
};
export const useRemoveImageOperation = () => {
@@ -4,6 +4,11 @@ import {
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemovePagesParameters,
@@ -11,22 +16,39 @@ import {
} from "@app/hooks/tools/removePages/useRemovePagesParameters";
// import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
const ENDPOINT = "/api/v1/general/remove-pages" satisfies ToolEndpoint;
type RemovePagesApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the remove-pages request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const removePagesToApiParams = (
parameters: RemovePagesParameters,
): RemovePagesApiParams => ({
pageNumbers: parameters.pageNumbers.replace(/\s+/g, ""),
});
// Reconstruct the tool's UI parameters from a remove-pages request body, so a
// stored or AI-authored step can be re-rendered in the settings UI.
export const removePagesFromApiParams = (
apiParams: RemovePagesApiParams,
): Partial<RemovePagesParameters> => ({
pageNumbers: apiParams.pageNumbers ?? defaultParameters.pageNumbers,
});
export const buildRemovePagesFormData = (
parameters: RemovePagesParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
const cleaned = parameters.pageNumbers.replace(/\s+/g, "");
formData.append("pageNumbers", cleaned);
return formData;
};
): FormData =>
objectToFormData(removePagesToApiParams(parameters), { fileInput: file });
export const removePagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePagesFormData,
toApiParams: removePagesToApiParams,
fromApiParams: removePagesFromApiParams,
operationType: "removePages",
endpoint: "/api/v1/general/remove-pages",
endpoint: ENDPOINT,
defaultParameters,
} as const satisfies ToolOperationConfig<RemovePagesParameters>;
@@ -1,4 +1,35 @@
import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
import {
RemovePasswordParameters,
defaultParameters,
} from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
// Defined here (not in the operation config) so both the mappers and the config
// share one endpoint constant without a circular import via FileContext.
export const REMOVE_PASSWORD_ENDPOINT =
"/api/v1/security/remove-password" satisfies ToolEndpoint;
type RemovePasswordApiParams = ToolApiParams[typeof REMOVE_PASSWORD_ENDPOINT];
// Convert the tool's UI parameters into the remove-password request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const removePasswordToApiParams = (
parameters: RemovePasswordParameters,
): RemovePasswordApiParams => ({
password: parameters.password,
});
// Reconstruct the tool's UI parameters from a remove-password request body, so a
// stored or AI-authored step can be re-rendered in the settings UI.
export const removePasswordFromApiParams = (
apiParams: RemovePasswordApiParams,
): Partial<RemovePasswordParameters> => ({
password: apiParams.password ?? defaultParameters.password,
});
/**
* Builds FormData for remove password API request.
@@ -7,9 +38,5 @@ import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRem
export const buildRemovePasswordFormData = (
parameters: RemovePasswordParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
return formData;
};
): FormData =>
objectToFormData(removePasswordToApiParams(parameters), { fileInput: file });
@@ -8,7 +8,12 @@ import {
RemovePasswordParameters,
defaultParameters,
} from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
import { buildRemovePasswordFormData } from "@app/hooks/tools/removePassword/buildRemovePasswordFormData";
import {
buildRemovePasswordFormData,
removePasswordToApiParams,
removePasswordFromApiParams,
REMOVE_PASSWORD_ENDPOINT,
} from "@app/hooks/tools/removePassword/buildRemovePasswordFormData";
// Re-export for backwards compatibility with any other imports
export { buildRemovePasswordFormData };
@@ -17,8 +22,10 @@ export { buildRemovePasswordFormData };
export const removePasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePasswordFormData,
toApiParams: removePasswordToApiParams,
fromApiParams: removePasswordFromApiParams,
operationType: "removePassword",
endpoint: "/api/v1/security/remove-password",
endpoint: REMOVE_PASSWORD_ENDPOINT,
defaultParameters,
} as const;
@@ -4,31 +4,64 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { ReorganizePagesParameters } from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters";
import {
ReorganizePagesParameters,
defaultReorganizePagesParameters,
} from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters";
const ENDPOINT = "/api/v1/general/rearrange-pages" satisfies ToolEndpoint;
type ReorganizePagesApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the rearrange-pages request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const reorganizePagesToApiParams = (
parameters: ReorganizePagesParameters,
): ReorganizePagesApiParams => {
const apiParams: ReorganizePagesApiParams = {};
if (parameters.customMode) {
apiParams.customMode =
parameters.customMode as ReorganizePagesApiParams["customMode"];
}
if (parameters.pageNumbers) {
apiParams.pageNumbers = parameters.pageNumbers.replace(/\s+/g, "");
}
return apiParams;
};
// Reconstruct the tool's UI parameters from a rearrange-pages request body, so a
// stored or AI-authored step can be re-rendered in the settings UI.
export const reorganizePagesFromApiParams = (
apiParams: ReorganizePagesApiParams,
): Partial<ReorganizePagesParameters> => ({
customMode:
apiParams.customMode ?? defaultReorganizePagesParameters.customMode,
pageNumbers:
apiParams.pageNumbers ?? defaultReorganizePagesParameters.pageNumbers,
});
const buildFormData = (
parameters: ReorganizePagesParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (parameters.customMode) {
formData.append("customMode", parameters.customMode);
}
if (parameters.pageNumbers) {
const cleaned = parameters.pageNumbers.replace(/\s+/g, "");
formData.append("pageNumbers", cleaned);
}
return formData;
};
): FormData =>
objectToFormData(reorganizePagesToApiParams(parameters), {
fileInput: file,
});
export const reorganizePagesOperationConfig: ToolOperationConfig<ReorganizePagesParameters> =
{
toolType: ToolType.singleFile,
buildFormData,
toApiParams: reorganizePagesToApiParams,
fromApiParams: reorganizePagesFromApiParams,
operationType: "reorganizePages",
endpoint: "/api/v1/general/rearrange-pages",
endpoint: ENDPOINT,
};
export const useReorganizePagesOperation = () => {
@@ -3,28 +3,35 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
fileOnlyMapping,
objectToFormData,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RepairParameters,
defaultParameters,
} from "@app/hooks/tools/repair/useRepairParameters";
// Static function that can be used by both the hook and automation executor
const ENDPOINT = "/api/v1/misc/repair" satisfies ToolEndpoint;
// Repair takes only a file; there are no request parameters to map.
const { toApiParams, fromApiParams } = fileOnlyMapping();
export const buildRepairFormData = (
_parameters: RepairParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
export const repairOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRepairFormData,
toApiParams,
fromApiParams,
operationType: "repair",
endpoint: "/api/v1/misc/repair",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -3,39 +3,72 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ReplaceColorParameters,
defaultParameters,
} from "@app/hooks/tools/replaceColor/useReplaceColorParameters";
const ENDPOINT = "/api/v1/misc/replace-invert-pdf" satisfies ToolEndpoint;
type ReplaceColorApiParams = ToolApiParams[typeof ENDPOINT];
export const replaceColorToApiParams = (
parameters: ReplaceColorParameters,
): ReplaceColorApiParams => {
const apiParams: ReplaceColorApiParams = {
replaceAndInvertOption: parameters.replaceAndInvertOption,
};
if (parameters.replaceAndInvertOption === "HIGH_CONTRAST_COLOR") {
apiParams.highContrastColorCombination =
parameters.highContrastColorCombination;
} else if (parameters.replaceAndInvertOption === "CUSTOM_COLOR") {
apiParams.textColor = parameters.textColor;
apiParams.backGroundColor = parameters.backGroundColor;
}
return apiParams;
};
export const replaceColorFromApiParams = (
apiParams: ReplaceColorApiParams,
): Partial<ReplaceColorParameters> => {
const result: Partial<ReplaceColorParameters> = {
replaceAndInvertOption: apiParams.replaceAndInvertOption,
};
if (apiParams.highContrastColorCombination !== undefined) {
result.highContrastColorCombination =
apiParams.highContrastColorCombination;
}
if (apiParams.textColor !== undefined) {
result.textColor = apiParams.textColor;
}
if (apiParams.backGroundColor !== undefined) {
result.backGroundColor = apiParams.backGroundColor;
}
return result;
};
export const buildReplaceColorFormData = (
parameters: ReplaceColorParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("replaceAndInvertOption", parameters.replaceAndInvertOption);
if (parameters.replaceAndInvertOption === "HIGH_CONTRAST_COLOR") {
formData.append(
"highContrastColorCombination",
parameters.highContrastColorCombination,
);
} else if (parameters.replaceAndInvertOption === "CUSTOM_COLOR") {
formData.append("textColor", parameters.textColor);
formData.append("backGroundColor", parameters.backGroundColor);
}
return formData;
};
): FormData =>
objectToFormData(replaceColorToApiParams(parameters), { fileInput: file });
export const replaceColorOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildReplaceColorFormData,
toApiParams: replaceColorToApiParams,
fromApiParams: replaceColorFromApiParams,
operationType: "replaceColor",
endpoint: "/api/v1/misc/replace-invert-pdf",
endpoint: ENDPOINT,
multiFileEndpoint: false,
defaultParameters,
} as const;
@@ -30,6 +30,10 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
rotateFromApiParams,
rotateToApiParams,
} from "@app/hooks/tools/rotate/useRotateOperation";
describe("useRotateOperation", () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
@@ -114,3 +118,30 @@ describe("useRotateOperation", () => {
expect(callArgs[property]).toBe(expectedValue);
});
});
describe("rotate mappers", () => {
test.each([
{ angle: 0, expected: 0 },
{ angle: 90, expected: 90 },
{ angle: -90, expected: 270 },
{ angle: 450, expected: 90 },
])(
"toApiParams normalizes angle $angle to $expected",
({ angle, expected }) => {
expect(rotateToApiParams({ angle }).angle).toBe(expected);
},
);
test("fromApiParams maps the backend angle back to the UI parameter", () => {
expect(rotateFromApiParams({ angle: 180 })).toEqual({ angle: 180 });
});
test.each([0, 90, 180, 270] as const)(
"round-trips a normalized angle %i",
(angle) => {
const ui = rotateFromApiParams({ angle });
const api = rotateToApiParams({ angle: ui.angle ?? 0 });
expect(api).toEqual({ angle });
},
);
});
@@ -3,6 +3,11 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RotateParameters,
@@ -10,24 +15,41 @@ import {
normalizeAngle,
} from "@app/hooks/tools/rotate/useRotateParameters";
const ENDPOINT = "/api/v1/general/rotate-pdf" satisfies ToolEndpoint;
type RotateApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the rotate-pdf request body. The return
// type is the generated backend model, so a spec change breaks the build here.
export const rotateToApiParams = (
parameters: RotateParameters,
): RotateApiParams => ({
// The UI angle can be any multiple of 90 (including negatives or values above
// 360); normalize to the four values the backend accepts.
angle: normalizeAngle(parameters.angle) as RotateApiParams["angle"],
});
// Reconstruct the tool's UI parameters from a rotate-pdf request body.
export const rotateFromApiParams = (
apiParams: RotateApiParams,
): Partial<RotateParameters> => ({
angle: apiParams.angle,
});
// Static configuration that can be used by both the hook and automation executor
export const buildRotateFormData = (
parameters: RotateParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Normalize angle for backend (0, 90, 180, 270)
formData.append("angle", normalizeAngle(parameters.angle).toString());
return formData;
};
): FormData =>
objectToFormData(rotateToApiParams(parameters), { fileInput: file });
// Static configuration object
export const rotateOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRotateFormData,
toApiParams: rotateToApiParams,
fromApiParams: rotateFromApiParams,
operationType: "rotate",
endpoint: "/api/v1/general/rotate-pdf",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -3,49 +3,65 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
SanitizeParameters,
defaultParameters,
} from "@app/hooks/tools/sanitize/useSanitizeParameters";
const ENDPOINT = "/api/v1/security/sanitize-pdf" satisfies ToolEndpoint;
type SanitizeApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the sanitize-pdf request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const sanitizeToApiParams = (
parameters: SanitizeParameters,
): SanitizeApiParams => ({
removeJavaScript: parameters.removeJavaScript ?? false,
removeEmbeddedFiles: parameters.removeEmbeddedFiles ?? false,
removeXMPMetadata: parameters.removeXMPMetadata ?? false,
removeMetadata: parameters.removeMetadata ?? false,
removeLinks: parameters.removeLinks ?? false,
removeFonts: parameters.removeFonts ?? false,
});
// Reconstruct the tool's UI parameters from a sanitize-pdf request body, so a
// stored or AI-authored step can be re-rendered in the settings UI.
export const sanitizeFromApiParams = (
apiParams: SanitizeApiParams,
): Partial<SanitizeParameters> => ({
removeJavaScript:
apiParams.removeJavaScript ?? defaultParameters.removeJavaScript,
removeEmbeddedFiles:
apiParams.removeEmbeddedFiles ?? defaultParameters.removeEmbeddedFiles,
removeXMPMetadata:
apiParams.removeXMPMetadata ?? defaultParameters.removeXMPMetadata,
removeMetadata: apiParams.removeMetadata ?? defaultParameters.removeMetadata,
removeLinks: apiParams.removeLinks ?? defaultParameters.removeLinks,
removeFonts: apiParams.removeFonts ?? defaultParameters.removeFonts,
});
// Static function that can be used by both the hook and automation executor
export const buildSanitizeFormData = (
parameters: SanitizeParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add parameters
formData.append(
"removeJavaScript",
(parameters.removeJavaScript ?? false).toString(),
);
formData.append(
"removeEmbeddedFiles",
(parameters.removeEmbeddedFiles ?? false).toString(),
);
formData.append(
"removeXMPMetadata",
(parameters.removeXMPMetadata ?? false).toString(),
);
formData.append(
"removeMetadata",
(parameters.removeMetadata ?? false).toString(),
);
formData.append("removeLinks", (parameters.removeLinks ?? false).toString());
formData.append("removeFonts", (parameters.removeFonts ?? false).toString());
return formData;
};
): FormData =>
objectToFormData(sanitizeToApiParams(parameters), { fileInput: file });
// Static configuration object
export const sanitizeOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSanitizeFormData,
toApiParams: sanitizeToApiParams,
fromApiParams: sanitizeFromApiParams,
operationType: "sanitize",
endpoint: "/api/v1/security/sanitize-pdf",
endpoint: ENDPOINT,
multiFileEndpoint: false,
defaultParameters,
} as const;
@@ -5,6 +5,11 @@ import {
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ScannerImageSplitParameters,
@@ -12,26 +17,50 @@ import {
} from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitParameters";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
const ENDPOINT = "/api/v1/misc/extract-image-scans" satisfies ToolEndpoint;
type ScannerImageSplitApiParams = ToolApiParams[typeof ENDPOINT];
// Convert the tool's UI parameters into the extract-image-scans request body.
// The frontend uses snake_case field names, but the backend model (the contract)
// uses camelCase, so the keys are renamed here.
export const scannerImageSplitToApiParams = (
parameters: ScannerImageSplitParameters,
): ScannerImageSplitApiParams => ({
angleThreshold: parameters.angle_threshold,
tolerance: parameters.tolerance,
minArea: parameters.min_area,
minContourArea: parameters.min_contour_area,
borderSize: parameters.border_size,
});
// Reconstruct the tool's UI parameters from an extract-image-scans request body,
// so a stored or AI-authored step can be re-rendered in the settings UI.
export const scannerImageSplitFromApiParams = (
apiParams: ScannerImageSplitApiParams,
): Partial<ScannerImageSplitParameters> => ({
angle_threshold: apiParams.angleThreshold,
tolerance: apiParams.tolerance,
min_area: apiParams.minArea,
min_contour_area: apiParams.minContourArea,
border_size: apiParams.borderSize,
});
export const buildScannerImageSplitFormData = (
parameters: ScannerImageSplitParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("angle_threshold", parameters.angle_threshold.toString());
formData.append("tolerance", parameters.tolerance.toString());
formData.append("min_area", parameters.min_area.toString());
formData.append("min_contour_area", parameters.min_contour_area.toString());
formData.append("border_size", parameters.border_size.toString());
return formData;
};
): FormData =>
objectToFormData(scannerImageSplitToApiParams(parameters), {
fileInput: file,
});
// Static configuration object
export const scannerImageSplitOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildScannerImageSplitFormData,
toApiParams: scannerImageSplitToApiParams,
fromApiParams: scannerImageSplitFromApiParams,
operationType: "scannerImageSplit",
endpoint: "/api/v1/misc/extract-image-scans",
endpoint: ENDPOINT,
defaultParameters,
} as const;
@@ -0,0 +1,163 @@
import { describe, expect, test } from "vitest";
import { type RegistryToolOperationConfig } from "@app/hooks/tools/shared/toolOperationTypes";
import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping";
// Pilot tools.
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import { rotateOperationConfig } from "@app/hooks/tools/rotate/useRotateOperation";
import { mergeOperationConfig } from "@app/hooks/tools/merge/useMergeOperation";
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
// Rolled out in Phase 3.
import { addAttachmentsOperationConfig } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation";
import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
import { addStampOperationConfig } from "@app/components/tools/addStamp/useAddStampOperation";
import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation";
import { editTableOfContentsOperationConfig } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation";
import { extractImagesOperationConfig } from "@app/hooks/tools/extractImages/useExtractImagesOperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
import { overlayPdfsOperationConfig } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsOperation";
import { pageLayoutOperationConfig } from "@app/hooks/tools/pageLayout/usePageLayoutOperation";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
import { removeBlanksOperationConfig } from "@app/hooks/tools/removeBlanks/useRemoveBlanksOperation";
import { removeCertificateSignOperationConfig } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
import { removeImageOperationConfig } from "@app/hooks/tools/removeImage/useRemoveImageOperation";
import { removePagesOperationConfig } from "@app/hooks/tools/removePages/useRemovePagesOperation";
import { removePasswordOperationConfig } from "@app/hooks/tools/removePassword/useRemovePasswordOperation";
import { reorganizePagesOperationConfig } from "@app/hooks/tools/reorganizePages/useReorganizePagesOperation";
import { repairOperationConfig } from "@app/hooks/tools/repair/useRepairOperation";
import { replaceColorOperationConfig } from "@app/hooks/tools/replaceColor/useReplaceColorOperation";
import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation";
import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import { singleLargePageOperationConfig } from "@app/hooks/tools/singleLargePage/useSingleLargePageOperation";
import { timestampPdfOperationConfig } from "@app/hooks/tools/timestampPdf/useTimestampPdfOperation";
import { unlockPdfFormsOperationConfig } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
// Every tool migrated to the mapper seam. Erased to the registry shape so one
// loop can invoke toApiParams(defaultParameters) uniformly regardless of the
// tool's own parameter type.
const MIGRATED_CONFIGS = [
compressOperationConfig,
rotateOperationConfig,
mergeOperationConfig,
splitOperationConfig,
addAttachmentsOperationConfig,
addPageNumbersOperationConfig,
addPasswordOperationConfig,
addStampOperationConfig,
addWatermarkOperationConfig,
adjustPageScaleOperationConfig,
autoRenameOperationConfig,
bookletImpositionOperationConfig,
certSignOperationConfig,
changePermissionsOperationConfig,
cropOperationConfig,
editTableOfContentsOperationConfig,
extractImagesOperationConfig,
flattenOperationConfig,
ocrOperationConfig,
overlayPdfsOperationConfig,
pageLayoutOperationConfig,
redactOperationConfig,
removeBlanksOperationConfig,
removeCertificateSignOperationConfig,
removeImageOperationConfig,
removePagesOperationConfig,
removePasswordOperationConfig,
reorganizePagesOperationConfig,
repairOperationConfig,
replaceColorOperationConfig,
sanitizeOperationConfig,
scannerImageSplitOperationConfig,
singleLargePageOperationConfig,
timestampPdfOperationConfig,
unlockPdfFormsOperationConfig,
// Erase each tool's own TParams to the shared registry shape (the same
// existential boundary asRegistryConfig applies) so one loop can call
// toApiParams(defaultParameters) uniformly.
] as unknown as RegistryToolOperationConfig[];
// A few tools have no static defaultParameters (the UI always supplies a value);
// give the sweep a minimal valid parameter set for those.
const FALLBACK_PARAMS: Record<string, Record<string, unknown>> = {
editTableOfContents: { bookmarks: [], replaceExisting: false },
};
describe("migrated tool mappers (sweep)", () => {
const file = new File(["x"], "test.pdf", { type: "application/pdf" });
test.each(
MIGRATED_CONFIGS.map((config) => [config.operationType, config] as const),
)(
"%s: exposes both mappers and serializes its default parameters cleanly",
(_name, config) => {
// Every migrated tool authors both directions of the mapping.
expect(config.toApiParams).toBeDefined();
expect(config.fromApiParams).toBeDefined();
// toApiParams(defaults) must produce a body objectToFormData can serialize
// (i.e. only primitives / arrays of primitives). A mapper that leaked a
// structured value would throw here.
const params =
config.defaultParameters ?? FALLBACK_PARAMS[config.operationType] ?? {};
const apiParams = config.toApiParams!(params);
expect(() =>
objectToFormData(apiParams, { fileInput: file }),
).not.toThrow();
},
);
});
describe("redact mappers", () => {
test("toApiParams builds the auto-redact body from UI parameters", () => {
const api = redactOperationConfig.toApiParams({
mode: "automatic",
wordsToRedact: ["foo", "bar"],
useRegex: true,
wholeWordSearch: false,
redactColor: "#ff0000",
customPadding: 0.2,
convertPDFToImage: false,
});
expect(api).toEqual({
listOfText: "foo\nbar",
useRegex: true,
wholeWordSearch: false,
redactColor: "ff0000", // '#' stripped for the backend
customPadding: 0.2,
convertPDFToImage: false,
});
});
test("round-trips through fromApiParams", () => {
const api = redactOperationConfig.toApiParams({
mode: "automatic",
wordsToRedact: ["secret"],
useRegex: false,
wholeWordSearch: true,
redactColor: "#123456",
customPadding: 0.1,
convertPDFToImage: true,
});
const roundTripped = redactOperationConfig.toApiParams({
mode: "automatic",
wordsToRedact: [],
useRegex: false,
wholeWordSearch: false,
redactColor: "#000000",
customPadding: 0,
convertPDFToImage: false,
...redactOperationConfig.fromApiParams(api),
});
expect(roundTripped).toEqual(api);
});
});

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