Merge branch 'main' into fix/reject-unteamed-policy-scope
This commit is contained in:
@@ -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,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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
# ============================================================
|
||||
|
||||
+34
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
+1
-2
@@ -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
@@ -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());
|
||||
|
||||
-28
@@ -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 {
|
||||
|
||||
+14
-14
@@ -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 {}
|
||||
|
||||
+321
@@ -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();
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+54
@@ -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 -> quote -> agreement -> payment -> 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;
|
||||
}
|
||||
}
|
||||
+119
@@ -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 1M–5M/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;
|
||||
}
|
||||
}
|
||||
+125
@@ -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 & 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
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
+244
@@ -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);
|
||||
+87
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
/dist-portal
|
||||
/storybook-static
|
||||
|
||||
/editor/build
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
+63
-15
@@ -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,
|
||||
});
|
||||
|
||||
+23
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+14
-3
@@ -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;
|
||||
|
||||
+47
-14
@@ -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;
|
||||
|
||||
+32
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+57
-18
@@ -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;
|
||||
|
||||
|
||||
+46
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
+56
-12
@@ -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>;
|
||||
|
||||
|
||||
+14
-7
@@ -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;
|
||||
|
||||
|
||||
+47
-14
@@ -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;
|
||||
|
||||
+40
-11
@@ -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
Reference in New Issue
Block a user