diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index 2d617f8f20..5e85976937 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -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" diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 223490f45f..554100f535 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -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 = ''; - 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 = ''; - 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e0a702cc2..8142c37fca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: | diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml new file mode 100644 index 0000000000..db9c49fba2 --- /dev/null +++ b/.github/workflows/check-generated-models.yml @@ -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 = ''; + 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 = ''; + 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, + }); + } diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 52277b5b04..4f87226f93 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -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] diff --git a/Taskfile.yml b/Taskfile.yml index 26895723d0..705ad4a1db 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 # ============================================================ diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java index 20d7eaf70b..222b89022a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java @@ -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; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java index 541a8717f3..2227803372 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java @@ -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") diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index 3e179eaf0c..2591d947a8 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -107,7 +107,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)", @@ -1283,8 +1283,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): diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts new file mode 100644 index 0000000000..f12572620a --- /dev/null +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -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.", +].join("\n"); + +type Json = Record; + +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 { + 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): 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 { + 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 --output [--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 = {}; + const usedClassNames = new Set(); + const pendingComponents = new Set(); + 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 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(); + 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, + outputPath: string, + check: boolean, + skipped: string[], +): Promise { + // 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, + }; + + // 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` - 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;", + ) + .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); +}); diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts b/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts index f9bf73f4ce..cb5624bce2 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts +++ b/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts b/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts index dc34c5d27a..bdceee49f1 100644 --- a/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts +++ b/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts @@ -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 => { + const result: Partial = { + 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; diff --git a/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts b/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts index 2b66bbdc34..bc9fb738bc 100644 --- a/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts @@ -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 => ({ + 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 = { 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 = () => { diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts index 3045c69450..77958629b0 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -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); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts index c09a740250..0bc3c1b396 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts new file mode 100644 index 0000000000..e00753e41c --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts @@ -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>([ + { 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); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts index 19700f43e7..75651f7bcd 100644 --- a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts @@ -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 => { + const result: Partial = { + 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; diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts index 95ee6f3293..e278097508 100644 --- a/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts +++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts @@ -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 => ({ + 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, + }); diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts index 264c71c940..7453ecfb3e 100644 --- a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts @@ -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); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts index c710737722..c25e12e3a9 100644 --- a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts +++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts @@ -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; diff --git a/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts b/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts index 26c900df66..faca5b0ae7 100644 --- a/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts +++ b/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts b/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts index 6da2b66320..f5a213d48c 100644 --- a/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts +++ b/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts index 8488dec77a..9f4767044e 100644 --- a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts +++ b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts @@ -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 => { + const result: Partial = { + 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; diff --git a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts index 2be13c3466..bad85097e2 100644 --- a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts @@ -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); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts index 0500d86417..dd0c532706 100644 --- a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts new file mode 100644 index 0000000000..4118eaf7d6 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts @@ -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 => ({ + ...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>([ + { 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"); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts index 6efa24e5d3..f62c981879 100644 --- a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts @@ -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 => { + const result: Partial = { + 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; diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts new file mode 100644 index 0000000000..eb03607d54 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts @@ -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); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts index a6e9bfe631..0500b8c77c 100644 --- a/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts +++ b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts @@ -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; }, }); }; diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts new file mode 100644 index 0000000000..806ad40af0 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts @@ -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 }>([ + { 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); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts index 67d72c8df0..24dc35daa3 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts new file mode 100644 index 0000000000..288beed847 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts @@ -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"); + }, + ); +}); diff --git a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts index 7f94008503..c395ed0521 100644 --- a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts @@ -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 => { + const result: Partial = { + 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 = { toolType: ToolType.singleFile, operationType: "editTableOfContents", - endpoint: "/api/v1/general/edit-table-of-contents", + endpoint: ENDPOINT, buildFormData, + toApiParams: editTableOfContentsToApiParams, + fromApiParams: editTableOfContentsFromApiParams, }; export const useEditTableOfContentsOperation = () => { diff --git a/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts b/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts index e52a9d6f2e..4f43ddf3ec 100644 --- a/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts +++ b/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts b/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts index 9138b824ed..7e01678e73 100644 --- a/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts +++ b/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts @@ -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 => { + const result: Partial = { + 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; diff --git a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts index c637f2fe53..21817ddfcd 100644 --- a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts @@ -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); @@ -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); + }); }); diff --git a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts index 44cee9e134..9b43f35fcd 100644 --- a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts +++ b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts @@ -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 => ({ + 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 = { toolType: ToolType.multiFile, buildFormData, + toApiParams: mergeToApiParams, + fromApiParams: mergeFromApiParams, operationType: "merge", - endpoint: "/api/v1/general/merge-pdfs", + endpoint: ENDPOINT, filePrefix: "merged_", defaultParameters, }; diff --git a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts index b7d6d56564..e9b1e92b2d 100644 --- a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts +++ b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts @@ -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 => { + 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; diff --git a/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts b/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts index 02b72e9e1d..448d70346d 100644 --- a/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts @@ -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 => ({ + 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 = { toolType: ToolType.singleFile, buildFormData, + toApiParams: overlayPdfsToApiParams, + fromApiParams: overlayPdfsFromApiParams, operationType: "overlayPdfs", - endpoint: "/api/v1/general/overlay-pdfs", + endpoint: ENDPOINT, }; export const useOverlayPdfsOperation = () => { diff --git a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts new file mode 100644 index 0000000000..874fdff811 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts @@ -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>([ + {}, + { 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); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts index b168f15f6f..dce2101cd4 100644 --- a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts +++ b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts index bc4db5b6c3..c7cefe1f43 100644 --- a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts +++ b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts b/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts index 5f24a28dd8..2cde7832e4 100644 --- a/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts b/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts index 1d6604f0ef..55d053fe09 100644 --- a/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts @@ -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; diff --git a/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts b/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts index 3441623cf6..2ba770c822 100644 --- a/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts @@ -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 = { toolType: ToolType.singleFile, buildFormData: buildRemoveImageFormData, + toApiParams, + fromApiParams, operationType: "removeImage", - endpoint: "/api/v1/general/remove-image-pdf", + endpoint: ENDPOINT, }; export const useRemoveImageOperation = () => { diff --git a/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts b/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts index 9ff5e6fb62..8d8f14f3b4 100644 --- a/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts b/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts index 8f04c4806f..c55040c7f2 100644 --- a/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts +++ b/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts @@ -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 => ({ + 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 }); diff --git a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts index aa71a5befb..2d516b861b 100644 --- a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts @@ -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; diff --git a/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts b/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts index de5aaf5c53..2bdb75f31c 100644 --- a/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts +++ b/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts @@ -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 => ({ + 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 = { toolType: ToolType.singleFile, buildFormData, + toApiParams: reorganizePagesToApiParams, + fromApiParams: reorganizePagesFromApiParams, operationType: "reorganizePages", - endpoint: "/api/v1/general/rearrange-pages", + endpoint: ENDPOINT, }; export const useReorganizePagesOperation = () => { diff --git a/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts b/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts index a62f448bac..ef60151d07 100644 --- a/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts +++ b/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts @@ -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; diff --git a/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts b/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts index dd24c10e22..94bbc32d9e 100644 --- a/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts +++ b/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts @@ -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 => { + const result: Partial = { + 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; diff --git a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts index 5db2b30788..96973db125 100644 --- a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts @@ -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 }); + }, + ); +}); diff --git a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts index e675610eb9..7e73aab99f 100644 --- a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts +++ b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts index 9c98b7d57f..93fd64240d 100644 --- a/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts +++ b/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts b/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts index e035c90ce5..27aadeb98b 100644 --- a/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts +++ b/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts @@ -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 => ({ + 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; diff --git a/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts b/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts new file mode 100644 index 0000000000..a809ae9604 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts @@ -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> = { + 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); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts new file mode 100644 index 0000000000..6ed02a49ae --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "vitest"; +import { + objectToFormData, + type ToolApiParams, +} from "@app/hooks/tools/shared/toolApiMapping"; + +describe("objectToFormData", () => { + test("serializes primitive fields to string form values", () => { + const request: ToolApiParams["/api/v1/misc/compress-pdf"] = { + optimizeLevel: 3, + grayscale: true, + linearize: false, + expectedOutputSize: "25KB", + }; + const formData = objectToFormData(request); + + expect(formData.get("optimizeLevel")).toBe("3"); + expect(formData.get("grayscale")).toBe("true"); + expect(formData.get("linearize")).toBe("false"); + expect(formData.get("expectedOutputSize")).toBe("25KB"); + }); + + test("omits fields whose value is undefined", () => { + const request: ToolApiParams["/api/v1/misc/compress-pdf"] = { + optimizeLevel: 5, + expectedOutputSize: undefined, + }; + const formData = objectToFormData(request); + + expect(formData.has("optimizeLevel")).toBe(true); + expect(formData.has("expectedOutputSize")).toBe(false); + }); + + test("expands arrays into repeated fields", () => { + const request: ToolApiParams["/api/v1/misc/add-attachments"] = { + attachments: ["a.png", "b.png", "c.png"], + }; + const formData = objectToFormData(request); + + expect(formData.getAll("attachments")).toEqual(["a.png", "b.png", "c.png"]); + }); + + test("throws on a non-primitive field value rather than dropping it", () => { + // A redact request whose structured field was left un-encoded: the array + // items are objects, which cannot be sent as form fields. + const request: ToolApiParams["/api/v1/security/redact"] = { + redactions: [{ x: 1, y: 2 }], + }; + + expect(() => objectToFormData(request)).toThrow(/field "redactions"/); + }); + + test("appends a single file under its field name", () => { + const file = new File(["x"], "doc.pdf", { type: "application/pdf" }); + const request: ToolApiParams["/api/v1/misc/compress-pdf"] = { + optimizeLevel: 5, + }; + const formData = objectToFormData(request, { fileInput: file }); + + expect(formData.get("fileInput")).toBe(file); + expect(formData.get("optimizeLevel")).toBe("5"); + }); + + test("appends multiple files under the same field name", () => { + const files = [ + new File(["1"], "a.pdf", { type: "application/pdf" }), + new File(["2"], "b.pdf", { type: "application/pdf" }), + ]; + const formData = objectToFormData({}, { fileInput: files }); + + expect(formData.getAll("fileInput")).toEqual(files); + }); + + test("appends named file fields alongside fileInput", () => { + const doc = new File(["d"], "doc.pdf", { type: "application/pdf" }); + const stamp = new File(["s"], "stamp.png", { type: "image/png" }); + const formData = objectToFormData( + {}, + { fileInput: doc, stampImage: stamp }, + ); + + expect(formData.get("fileInput")).toBe(doc); + expect(formData.get("stampImage")).toBe(stamp); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts new file mode 100644 index 0000000000..760df9ce5e --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts @@ -0,0 +1,88 @@ +import { + type ToolApiParams, + type ToolApiRequest, + type ToolEndpoint, +} from "@app/types/toolApiTypes"; + +export type { ToolApiParams, ToolApiRequest, ToolEndpoint }; + +/** + * Mapping for tools that take only a file and have no request parameters (their + * generated model is `Record`). Both directions are empty; the + * tool's buildFormData just appends the file. + */ +export function fileOnlyMapping(): { + toApiParams: () => Record; + fromApiParams: () => Record; +} { + return { toApiParams: () => ({}), fromApiParams: () => ({}) }; +} + +/** Named file fields to append alongside the serialized parameters. */ +export interface FormDataFiles { + /** Primary document input(s); appended under the `fileInput` field. */ + fileInput?: File | File[]; + /** Any other named file field the endpoint accepts. */ + [field: string]: File | File[] | undefined; +} + +function appendPrimitive( + formData: FormData, + key: string, + value: unknown, +): void { + if (value === undefined || value === null) return; + if (typeof value === "string") { + formData.append(key, value); + } else if (typeof value === "number" || typeof value === "boolean") { + formData.append(key, `${value}`); + } else { + // A non-primitive here means a mapper produced a value the backend cannot + // receive as a form field. Fail loudly rather than silently drop it: + // structured fields must be JSON-encoded in the mapper, and Files passed via + // the `files` argument. + throw new Error( + `objectToFormData: field "${key}" has an unsupported value of type ` + + `"${typeof value}"; expected a string, number, or boolean.`, + ); + } +} + +/** + * Serialize a backend request model (the output of a `toApiParams` function) + * into multipart FormData: primitives become string fields, arrays become + * repeated fields, and `undefined`/`null` are omitted. Files are appended + * separately via `files`, keeping file plumbing out of the parameter mapper. + * + * Throws if a field holds a non-primitive value, since that cannot be sent as a + * form field: structured fields must be JSON-encoded by the mapper. + */ +export function objectToFormData( + params: ToolApiRequest, + files?: FormDataFiles, +): FormData { + const formData = new FormData(); + + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (const item of value) { + appendPrimitive(formData, key, item); + } + } else { + appendPrimitive(formData, key, value); + } + } + + if (files) { + for (const [field, value] of Object.entries(files)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + value.forEach((file) => formData.append(field, file)); + } else { + formData.append(field, value); + } + } + } + + return formData; +} diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts index 0d48ce6d09..93ade85368 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -3,9 +3,16 @@ import { StirlingFile } from "@app/types/fileContext"; import type { ResponseHandler } from "@app/utils/toolResponseProcessor"; import { ToolId } from "@app/types/toolId"; import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState"; +import type { ToolApiRequest, ToolEndpoint } from "@app/types/toolApiTypes"; export type { ProcessingProgress, ResponseHandler }; +/** + * A tool operation's backend endpoint, checked against the generated ToolEndpoint + * set, or `null` when the operation has no backend endpoint. + */ +export type ToolOperationEndpoint = ToolEndpoint | null; + export enum ToolType { singleFile, multiFile, @@ -72,6 +79,19 @@ interface BaseToolOperationConfig { /** Default parameter values for automation */ defaultParameters?: TParams; + /** + * Typed frontend params -> backend request model. When a tool provides this, + * it is the spec-checked source of truth for the request body and its + * buildFormData is derived from it via objectToFormData. + */ + toApiParams?(params: TParams): ToolApiRequest; + + /** + * Backend request model -> partial frontend params, so a stored API call + * can be re-hydrated into this tool's settings UI. + */ + fromApiParams?(apiParams: ToolApiRequest): Partial; + /** * For custom tools: if true, success implies all input files were successfully processed. * Use this for tools like Automate or Merge where Many-to-One relationships exist @@ -89,8 +109,12 @@ export interface SingleFileToolOperationConfig< /** Builds FormData for API request. */ buildFormData: (params: TParams, file: File) => FormData; - /** API endpoint for the operation. Can be static string or function for dynamic routing. */ - endpoint: string | ((params: TParams) => string); + /** + * API endpoint for the operation. Can be static or a function for dynamic routing. + */ + endpoint: + | ToolOperationEndpoint + | ((params: TParams) => ToolOperationEndpoint); customProcessor?: undefined; } @@ -107,8 +131,12 @@ export interface MultiFileToolOperationConfig< /** Builds FormData for API request. */ buildFormData: (params: TParams, files: File[]) => FormData; - /** API endpoint for the operation. Can be static string or function for dynamic routing. */ - endpoint: string | ((params: TParams) => string); + /** + * API endpoint for the operation. Can be static or a function for dynamic routing. + */ + endpoint: + | ToolOperationEndpoint + | ((params: TParams) => ToolOperationEndpoint); customProcessor?: undefined; } diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts b/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts index 28da62484a..cca1f5e563 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts @@ -10,7 +10,7 @@ import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState"; import type { StirlingFile, FileId } from "@app/types/fileContext"; export interface ApiCallsConfig { - endpoint: string | ((params: TParams) => string); + endpoint: string | null | ((params: TParams) => string | null); buildFormData: (params: TParams, file: File) => FormData; filePrefix?: string; responseHandler?: ResponseHandler; @@ -37,6 +37,19 @@ export const useToolApiCalls = () => { // Create cancel token for this operation cancelTokenRef.current = axios.CancelToken.source(); + // Params are the same for every file, so resolve the endpoint once. A null + // endpoint means the tool has no backend call (e.g. client-side tools) and + // should never reach here, so fail loudly rather than POST to null. + const endpoint = + typeof config.endpoint === "function" + ? config.endpoint(params) + : config.endpoint; + if (!endpoint) { + throw new Error( + "This operation has no backend endpoint and cannot be executed directly.", + ); + } + for (let i = 0; i < validFiles.length; i++) { const file = validFiles[i]; @@ -51,10 +64,6 @@ export const useToolApiCalls = () => { try { const formData = config.buildFormData(params, file); - const endpoint = - typeof config.endpoint === "function" - ? config.endpoint(params) - : config.endpoint; console.debug("[processFiles] POST", { endpoint, name: file.name }); const response = await apiClient.post(endpoint, formData, { responseType: "blob", diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index 5629dfe54b..17e4c47442 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -266,6 +266,11 @@ export const useToolOperation = ( typeof config.endpoint === "function" ? config.endpoint(params) : config.endpoint; + if (!endpoint) { + throw new Error( + "This operation has no backend endpoint and cannot be executed directly.", + ); + } const response = await apiClient.post(endpoint, formData, { responseType: "blob", diff --git a/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts b/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts index bd92fdd758..175fa744ab 100644 --- a/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts +++ b/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts @@ -54,7 +54,10 @@ export const signOperationConfig = { toolType: ToolType.singleFile, buildFormData: buildSignFormData, operationType: "sign", - endpoint: "/api/v1/security/add-signature", + // Signing is applied client-side in the viewer (see createStampTool -> + // flattenSignatures); there is no backend endpoint and the standard execute + // path is never used. + endpoint: null, filePrefix: "signed_", defaultParameters: DEFAULT_PARAMETERS, } as const; diff --git a/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts b/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts index a57a26a7dc..835959c484 100644 --- a/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts +++ b/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts @@ -3,28 +3,36 @@ 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 { SingleLargePageParameters, defaultParameters, } from "@app/hooks/tools/singleLargePage/useSingleLargePageParameters"; +const ENDPOINT = "/api/v1/general/pdf-to-single-page" satisfies ToolEndpoint; + +// Single large page takes only a file; there are no request parameters to map. +const { toApiParams, fromApiParams } = fileOnlyMapping(); + // Static function that can be used by both the hook and automation executor export const buildSingleLargePageFormData = ( _parameters: SingleLargePageParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - return formData; -}; +): FormData => objectToFormData(toApiParams(), { fileInput: file }); // Static configuration object export const singleLargePageOperationConfig = { toolType: ToolType.singleFile, buildFormData: buildSingleLargePageFormData, + toApiParams, + fromApiParams, operationType: "pdfToSinglePage", - endpoint: "/api/v1/general/pdf-to-single-page", + endpoint: ENDPOINT, defaultParameters, } as const; diff --git a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts new file mode 100644 index 0000000000..4cf8509c5d --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "vitest"; +import { + buildSplitFormData, + getSplitEndpoint, + splitFromApiParams, + splitToApiParams, +} from "@app/hooks/tools/split/useSplitOperation"; +import { + SplitParameters, + defaultParameters, +} from "@app/hooks/tools/split/useSplitParameters"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; + +const params = (overrides: Partial): SplitParameters => ({ + ...defaultParameters, + ...overrides, +}); + +describe("splitToApiParams", () => { + test("byPages sends pageNumbers", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_PAGES, pages: "2,5" }), + ), + ).toEqual({ pageNumbers: "2,5" }); + }); + + test("bySections sends divisions and split mode without custom pages", () => { + expect( + splitToApiParams( + params({ + method: SPLIT_METHODS.BY_SECTIONS, + hDiv: "3", + vDiv: "2", + merge: true, + splitMode: "SPLIT_ALL", + }), + ), + ).toEqual({ + horizontalDivisions: 3, + verticalDivisions: 2, + merge: true, + splitMode: "SPLIT_ALL", + }); + }); + + test("bySections includes pageNumbers only for CUSTOM mode", () => { + expect( + splitToApiParams( + params({ + method: SPLIT_METHODS.BY_SECTIONS, + splitMode: "CUSTOM", + customPages: "1,2", + }), + ), + ).toMatchObject({ splitMode: "CUSTOM", pageNumbers: "1,2" }); + }); + + test.each([ + { method: SPLIT_METHODS.BY_SIZE, splitType: 0 }, + { method: SPLIT_METHODS.BY_PAGE_COUNT, splitType: 1 }, + { method: SPLIT_METHODS.BY_DOC_COUNT, splitType: 2 }, + ])("$method maps to splitType $splitType", ({ method, splitType }) => { + expect(splitToApiParams(params({ method, splitValue: "5" }))).toEqual({ + splitType, + splitValue: "5", + }); + }); + + test("byChapters converts bookmarkLevel to a number", () => { + expect( + splitToApiParams( + params({ + method: SPLIT_METHODS.BY_CHAPTERS, + bookmarkLevel: "2", + includeMetadata: true, + }), + ), + ).toEqual({ + bookmarkLevel: 2, + includeMetadata: true, + allowDuplicates: false, + }); + }); + + test("byPageDivider sends duplexMode", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_PAGE_DIVIDER, duplexMode: true }), + ), + ).toEqual({ duplexMode: true }); + }); + + test("byPoster maps the factors to the spec's xFactor/yFactor fields", () => { + expect( + splitToApiParams( + params({ + method: SPLIT_METHODS.BY_POSTER, + pageSize: "A4", + xFactor: "3", + yFactor: "2", + rightToLeft: true, + }), + ), + ).toEqual({ pageSize: "A4", xFactor: 3, yFactor: 2, rightToLeft: true }); + }); + + // A cleared numeric field arrives as "". It must fall back to the default, + // not Number("") === 0, which the backend turns into an empty/degenerate PDF. + test("byPoster falls back to the default factor for empty fields", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_POSTER, xFactor: "", yFactor: "" }), + ), + ).toMatchObject({ xFactor: 2, yFactor: 2 }); + }); + + test("bySections falls back to the default divisions for empty fields", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_SECTIONS, hDiv: "", vDiv: "" }), + ), + ).toMatchObject({ horizontalDivisions: 2, verticalDivisions: 2 }); + }); + + test("byChapters falls back to the default bookmark level for an empty field", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_CHAPTERS, bookmarkLevel: "" }), + ), + ).toMatchObject({ bookmarkLevel: 1 }); + }); +}); + +describe("split round-trip", () => { + test.each>([ + { method: SPLIT_METHODS.BY_PAGES, pages: "2,5" }, + { + method: SPLIT_METHODS.BY_SECTIONS, + hDiv: "3", + vDiv: "2", + merge: true, + splitMode: "SPLIT_ALL", + }, + { + method: SPLIT_METHODS.BY_SECTIONS, + splitMode: "CUSTOM", + customPages: "1,2", + }, + { method: SPLIT_METHODS.BY_SIZE, splitValue: "10MB" }, + { method: SPLIT_METHODS.BY_PAGE_COUNT, splitValue: "5" }, + { method: SPLIT_METHODS.BY_DOC_COUNT, splitValue: "3" }, + { + method: SPLIT_METHODS.BY_CHAPTERS, + bookmarkLevel: "2", + includeMetadata: true, + }, + { method: SPLIT_METHODS.BY_PAGE_DIVIDER, duplexMode: true }, + { + method: SPLIT_METHODS.BY_POSTER, + pageSize: "A4", + xFactor: "3", + yFactor: "2", + }, + ])("toApiParams(fromApiParams(x)) reproduces x for %o", (overrides) => { + const api = splitToApiParams(params(overrides)); + const roundTripped = splitToApiParams(params(splitFromApiParams(api))); + + expect(roundTripped).toEqual(api); + }); +}); + +describe("getSplitEndpoint", () => { + test.each([ + { method: SPLIT_METHODS.BY_PAGES, endpoint: "/api/v1/general/split-pages" }, + { + method: SPLIT_METHODS.BY_SECTIONS, + endpoint: "/api/v1/general/split-pdf-by-sections", + }, + { + method: SPLIT_METHODS.BY_SIZE, + endpoint: "/api/v1/general/split-by-size-or-count", + }, + { + method: SPLIT_METHODS.BY_CHAPTERS, + endpoint: "/api/v1/general/split-pdf-by-chapters", + }, + { + method: SPLIT_METHODS.BY_PAGE_DIVIDER, + endpoint: "/api/v1/misc/auto-split-pdf", + }, + { + method: SPLIT_METHODS.BY_POSTER, + endpoint: "/api/v1/general/split-for-poster-print", + }, + ])("$method routes to $endpoint", ({ method, endpoint }) => { + expect(getSplitEndpoint(params({ method }))).toBe(endpoint); + }); +}); + +describe("buildSplitFormData", () => { + test("appends the file and the serialized parameters", () => { + const file = new File(["x"], "test.pdf", { type: "application/pdf" }); + const formData = buildSplitFormData( + params({ method: SPLIT_METHODS.BY_PAGES, pages: "3" }), + file, + ); + + expect(formData.get("fileInput")).toBe(file); + expect(formData.get("pageNumbers")).toBe("3"); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts index bedf83d09e..adeb65546a 100644 --- a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts +++ b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts @@ -5,115 +5,176 @@ 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 { SplitParameters, defaultParameters, } from "@app/hooks/tools/split/useSplitParameters"; -import { SPLIT_METHODS } from "@app/constants/splitConstants"; +import { SPLIT_METHODS, type SplitMethod } from "@app/constants/splitConstants"; import { useToolResources } from "@app/hooks/tools/shared/useToolResources"; -// Static functions that can be used by both the hook and automation executor -export const buildSplitFormData = ( +// Split routes to a different endpoint per method. This map is the single source +// of truth: getSplitEndpoint returns from it, and the mapper types below are +// derived from it, so the endpoint posted and the request shape checked can +// never point at different endpoints. +const SPLIT_ENDPOINTS = { + [SPLIT_METHODS.BY_PAGES]: "/api/v1/general/split-pages", + [SPLIT_METHODS.BY_SECTIONS]: "/api/v1/general/split-pdf-by-sections", + [SPLIT_METHODS.BY_SIZE]: "/api/v1/general/split-by-size-or-count", + [SPLIT_METHODS.BY_PAGE_COUNT]: "/api/v1/general/split-by-size-or-count", + [SPLIT_METHODS.BY_DOC_COUNT]: "/api/v1/general/split-by-size-or-count", + [SPLIT_METHODS.BY_CHAPTERS]: "/api/v1/general/split-pdf-by-chapters", + [SPLIT_METHODS.BY_PAGE_DIVIDER]: "/api/v1/misc/auto-split-pdf", + [SPLIT_METHODS.BY_POSTER]: "/api/v1/general/split-for-poster-print", +} as const satisfies Record; + +type SplitApiParams = ToolApiParams[(typeof SPLIT_ENDPOINTS)[SplitMethod]]; +type SectionsApiParams = + ToolApiParams[(typeof SPLIT_ENDPOINTS)[typeof SPLIT_METHODS.BY_SECTIONS]]; +type PosterApiParams = + ToolApiParams[(typeof SPLIT_ENDPOINTS)[typeof SPLIT_METHODS.BY_POSTER]]; + +// Convert the tool's UI parameters into the request body for the routed endpoint. +export const splitToApiParams = ( parameters: SplitParameters, - file: File, -): FormData => { - const formData = new FormData(); - - formData.append("fileInput", file); - +): SplitApiParams => { // Use BY_PAGES as default if no method is selected const method = parameters.method || SPLIT_METHODS.BY_PAGES; switch (method) { case SPLIT_METHODS.BY_PAGES: - formData.append("pageNumbers", parameters.pages); - break; - case SPLIT_METHODS.BY_SECTIONS: - formData.append("horizontalDivisions", parameters.hDiv); - formData.append("verticalDivisions", parameters.vDiv); - formData.append("merge", (parameters.merge ?? false).toString()); - formData.append("splitMode", parameters.splitMode || "SPLIT_ALL"); + return { pageNumbers: parameters.pages }; + case SPLIT_METHODS.BY_SECTIONS: { + const sections: SectionsApiParams = { + horizontalDivisions: Number(parameters.hDiv || "2"), + verticalDivisions: Number(parameters.vDiv || "2"), + merge: parameters.merge ?? false, + splitMode: (parameters.splitMode || + "SPLIT_ALL") as SectionsApiParams["splitMode"], + }; if (parameters.splitMode === "CUSTOM" && parameters.customPages) { - formData.append("pageNumbers", parameters.customPages); + sections.pageNumbers = parameters.customPages; } - break; + return sections; + } case SPLIT_METHODS.BY_SIZE: - formData.append("splitType", "0"); - formData.append("splitValue", parameters.splitValue); - break; + return { splitType: 0, splitValue: parameters.splitValue }; case SPLIT_METHODS.BY_PAGE_COUNT: - formData.append("splitType", "1"); - formData.append("splitValue", parameters.splitValue); - break; + return { splitType: 1, splitValue: parameters.splitValue }; case SPLIT_METHODS.BY_DOC_COUNT: - formData.append("splitType", "2"); - formData.append("splitValue", parameters.splitValue); - break; + return { splitType: 2, splitValue: parameters.splitValue }; case SPLIT_METHODS.BY_CHAPTERS: - formData.append("bookmarkLevel", parameters.bookmarkLevel); - formData.append( - "includeMetadata", - (parameters.includeMetadata ?? false).toString(), - ); - formData.append( - "allowDuplicates", - (parameters.allowDuplicates ?? false).toString(), - ); - break; + return { + bookmarkLevel: Number(parameters.bookmarkLevel || "1"), + includeMetadata: parameters.includeMetadata ?? false, + allowDuplicates: parameters.allowDuplicates ?? false, + }; case SPLIT_METHODS.BY_PAGE_DIVIDER: - formData.append( - "duplexMode", - (parameters.duplexMode ?? false).toString(), - ); - break; + return { duplexMode: parameters.duplexMode ?? false }; case SPLIT_METHODS.BY_POSTER: - formData.append("pageSize", parameters.pageSize || "A4"); - formData.append("xFactor", parameters.xFactor || "2"); - formData.append("yFactor", parameters.yFactor || "2"); - formData.append( - "rightToLeft", - (parameters.rightToLeft ?? false).toString(), - ); - break; + return { + pageSize: (parameters.pageSize || "A4") as PosterApiParams["pageSize"], + xFactor: Number(parameters.xFactor || "2"), + yFactor: Number(parameters.yFactor || "2"), + rightToLeft: parameters.rightToLeft ?? false, + }; default: throw new Error(`Unknown split method: ${method}`); } - - return formData; }; -export const getSplitEndpoint = (parameters: SplitParameters): string => { - // Default to BY_PAGES endpoint if no method selected yet - if (!parameters.method) { - return "/api/v1/general/split-pages"; +// Reconstruct the tool's UI parameters from a stored request body. The step +// carries no explicit method, so it is inferred from the fields present. +export const splitFromApiParams = ( + apiParams: SplitApiParams, +): Partial => { + if ("pageSize" in apiParams) { + return { + method: SPLIT_METHODS.BY_POSTER, + pageSize: apiParams.pageSize, + xFactor: + apiParams.xFactor !== undefined ? `${apiParams.xFactor}` : undefined, + yFactor: + apiParams.yFactor !== undefined ? `${apiParams.yFactor}` : undefined, + rightToLeft: apiParams.rightToLeft ?? defaultParameters.rightToLeft, + }; } - - switch (parameters.method) { - case null: - case SPLIT_METHODS.BY_PAGES: - return "/api/v1/general/split-pages"; - case SPLIT_METHODS.BY_SECTIONS: - return "/api/v1/general/split-pdf-by-sections"; - case SPLIT_METHODS.BY_SIZE: - case SPLIT_METHODS.BY_PAGE_COUNT: - case SPLIT_METHODS.BY_DOC_COUNT: - return "/api/v1/general/split-by-size-or-count"; - case SPLIT_METHODS.BY_CHAPTERS: - return "/api/v1/general/split-pdf-by-chapters"; - case SPLIT_METHODS.BY_PAGE_DIVIDER: - return "/api/v1/misc/auto-split-pdf"; - case SPLIT_METHODS.BY_POSTER: - return "/api/v1/general/split-for-poster-print"; - default: - throw new Error(`Unknown split method: ${parameters.method}`); + if ("horizontalDivisions" in apiParams || "verticalDivisions" in apiParams) { + return { + method: SPLIT_METHODS.BY_SECTIONS, + hDiv: + apiParams.horizontalDivisions !== undefined + ? `${apiParams.horizontalDivisions}` + : undefined, + vDiv: + apiParams.verticalDivisions !== undefined + ? `${apiParams.verticalDivisions}` + : undefined, + merge: apiParams.merge ?? false, + splitMode: apiParams.splitMode ?? "SPLIT_ALL", + customPages: + apiParams.splitMode === "CUSTOM" + ? apiParams.pageNumbers + : defaultParameters.customPages, + }; } + if ("bookmarkLevel" in apiParams) { + return { + method: SPLIT_METHODS.BY_CHAPTERS, + bookmarkLevel: + apiParams.bookmarkLevel !== undefined + ? `${apiParams.bookmarkLevel}` + : "", + includeMetadata: apiParams.includeMetadata ?? false, + allowDuplicates: apiParams.allowDuplicates ?? false, + }; + } + if ("splitType" in apiParams) { + const methodBySplitType = { + 0: SPLIT_METHODS.BY_SIZE, + 1: SPLIT_METHODS.BY_PAGE_COUNT, + 2: SPLIT_METHODS.BY_DOC_COUNT, + } as const; + return { + method: methodBySplitType[apiParams.splitType as 0 | 1 | 2], + splitValue: apiParams.splitValue ?? "", + }; + } + if ("duplexMode" in apiParams) { + return { + method: SPLIT_METHODS.BY_PAGE_DIVIDER, + duplexMode: apiParams.duplexMode ?? false, + }; + } + const pages = "pageNumbers" in apiParams ? apiParams.pageNumbers : undefined; + return { + method: SPLIT_METHODS.BY_PAGES, + pages: pages ?? defaultParameters.pages, + }; }; +// Static functions that can be used by both the hook and automation executor +export const buildSplitFormData = ( + parameters: SplitParameters, + file: File, +): FormData => + objectToFormData(splitToApiParams(parameters), { fileInput: file }); + +export const getSplitEndpoint = (parameters: SplitParameters): ToolEndpoint => + // Default to BY_PAGES when no method is selected yet. + SPLIT_ENDPOINTS[parameters.method ?? SPLIT_METHODS.BY_PAGES]; + // Static configuration object export const splitOperationConfig = { toolType: ToolType.singleFile, buildFormData: buildSplitFormData, + toApiParams: splitToApiParams, + fromApiParams: splitFromApiParams, operationType: "split", endpoint: getSplitEndpoint, defaultParameters, diff --git a/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts b/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts index b14f785574..f607a727f0 100644 --- a/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts +++ b/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts @@ -3,29 +3,45 @@ 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 { TimestampPdfParameters, defaultParameters, } from "@app/hooks/tools/timestampPdf/useTimestampPdfParameters"; +const ENDPOINT = "/api/v1/security/timestamp-pdf" satisfies ToolEndpoint; +type TimestampPdfApiParams = ToolApiParams[typeof ENDPOINT]; + +export const timestampPdfToApiParams = ( + parameters: TimestampPdfParameters, +): TimestampPdfApiParams => ({ + tsaUrl: parameters.tsaUrl, +}); + +export const timestampPdfFromApiParams = ( + apiParams: TimestampPdfApiParams, +): Partial => ({ + tsaUrl: apiParams.tsaUrl, +}); + export const buildTimestampPdfFormData = ( parameters: TimestampPdfParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - formData.append("tsaUrl", parameters.tsaUrl); - - return formData; -}; +): FormData => + objectToFormData(timestampPdfToApiParams(parameters), { fileInput: file }); export const timestampPdfOperationConfig = { toolType: ToolType.singleFile, buildFormData: buildTimestampPdfFormData, + toApiParams: timestampPdfToApiParams, + fromApiParams: timestampPdfFromApiParams, operationType: "timestampPdf", - endpoint: "/api/v1/security/timestamp-pdf", + endpoint: ENDPOINT, multiFileEndpoint: false, defaultParameters, } as const; diff --git a/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts b/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts index 1e78d2d407..2d3e22fb06 100644 --- a/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts @@ -3,28 +3,36 @@ 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 { UnlockPdfFormsParameters, defaultParameters, } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters"; +const ENDPOINT = "/api/v1/misc/unlock-pdf-forms" satisfies ToolEndpoint; + +// Unlock PDF forms takes only a file; there are no request parameters to map. +const { toApiParams, fromApiParams } = fileOnlyMapping(); + // Static function that can be used by both the hook and automation executor export const buildUnlockPdfFormsFormData = ( _parameters: UnlockPdfFormsParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - return formData; -}; +): FormData => objectToFormData(toApiParams(), { fileInput: file }); // Static configuration object export const unlockPdfFormsOperationConfig = { toolType: ToolType.singleFile, buildFormData: buildUnlockPdfFormsFormData, + toApiParams, + fromApiParams, operationType: "unlockPDFForms", - endpoint: "/api/v1/misc/unlock-pdf-forms", + endpoint: ENDPOINT, defaultParameters, } as const; diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts new file mode 100644 index 0000000000..d62e13b20f --- /dev/null +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -0,0 +1,1594 @@ +// 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. + +export interface AddAttachmentRequest { + /** + * The image file to be overlaid onto the PDF. + */ + attachments: string[]; + /** + * Convert the resulting PDF to PDF/A-3b format after adding attachments + */ + convertToPdfA3b?: boolean; +} +export interface AddCommentsRequest { + /** + * JSON array of comment specs. Each element has: {pageIndex, x, y, width, height, text, author?, subject?}. Coordinates are PDF user-space with origin at the page's bottom-left. + */ + comments: string; +} +export interface AddPageNumbersRequest { + /** + * Custom margin: small/medium/large/x-large + */ + customMargin?: "small" | "medium" | "large" | "x-large"; + /** + * Custom text pattern. Available variables: {n}=current page number, {total}=total pages, {filename}=original filename + */ + customText?: string; + /** + * Hex colour for page numbers (e.g. #FF0000) + */ + fontColor?: string; + /** + * Font size for page numbers + */ + fontSize?: number; + /** + * Font type for page numbers + */ + fontType: "helvetica" | "courier" | "times"; + /** + * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5') + */ + pageNumbers?: string; + /** + * Which pages to number (e.g. '1,3-5,7' or 'all') + */ + pagesToNumber?: string; + /** + * Position: 1-9 representing positions on the page (1=top-left, 2=top-center, 3=top-right, 4=middle-left, 5=middle-center, 6=middle-right, 7=bottom-left, 8=bottom-center, 9=bottom-right) + */ + position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + /** + * Starting number for page numbering + */ + startingNumber?: number; + /** + * Zero-padding width for page numbers (Bates Stamping). Set to 0 to disable padding + */ + zeroPad?: number; +} +export interface AddPasswordRequest { + /** + * The length of the encryption key + */ + keyLength?: 40 | 128 | 256; + /** + * The owner password to be added to the PDF file (Restricts what can be done with the document once it is opened) + */ + ownerPassword?: string; + /** + * The password to be added to the PDF file (Restricts the opening of the document itself.) + */ + password?: string; + /** + * Whether document assembly is prevented + */ + preventAssembly?: boolean; + /** + * Whether content extraction is prevented + */ + preventExtractContent?: boolean; + /** + * Whether content extraction for accessibility is prevented + */ + preventExtractForAccessibility?: boolean; + /** + * Whether form filling is prevented + */ + preventFillInForm?: boolean; + /** + * Whether document modification is prevented + */ + preventModify?: boolean; + /** + * Whether modification of annotations is prevented + */ + preventModifyAnnotations?: boolean; + /** + * Whether printing of the document is prevented + */ + preventPrinting?: boolean; + /** + * Whether faithful printing is prevented + */ + preventPrintingFaithful?: boolean; +} +export interface AddStampRequest { + /** + * The selected alphabet of the stamp text + */ + alphabet?: "roman" | "arabic" | "japanese" | "korean" | "chinese" | "thai"; + /** + * The color of the stamp text + */ + customColor?: string; + /** + * Specifies the margin size for the stamp. + */ + customMargin?: "small" | "medium" | "large" | "x-large"; + /** + * The font size of the stamp text and image in points. + */ + fontSize?: number; + /** + * The opacity of the stamp (0.0 - 1.0) + */ + opacity?: number; + /** + * Override X coordinate for stamp placement. If set, it will override the position-based calculation. Negative value means no override. + */ + overrideX?: number; + /** + * Override Y coordinate for stamp placement. If set, it will override the position-based calculation. Negative value means no override. + */ + overrideY?: number; + /** + * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5') + */ + pageNumbers?: string; + /** + * Position for stamp placement based on a 1-9 grid (1: bottom-left, 2: bottom-center, 3: bottom-right, 4: middle-left, 5: middle-center, 6: middle-right, 7: top-left, 8: top-center, 9: top-right) + */ + position?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + /** + * The rotation of the stamp in degrees + */ + rotation?: number; + stampImage?: string; + /** + * The stamp text + */ + stampText?: string; + /** + * The stamp type (text or image) + */ + stampType: "text" | "image"; +} +export interface AddWatermarkRequest { + /** + * The selected alphabet + */ + alphabet?: "roman" | "arabic" | "japanese" | "korean" | "chinese" | "thai"; + /** + * Convert the redacted PDF to an image + */ + convertPDFToImage?: boolean; + /** + * The color for watermark + */ + customColor?: string; + /** + * The font size of the watermark text + */ + fontSize?: number; + /** + * The height spacer between watermark elements + */ + heightSpacer?: number; + /** + * The opacity of the watermark (0.0 - 1.0) + */ + opacity?: number; + /** + * The rotation of the watermark in degrees + */ + rotation?: number; + watermarkImage?: string; + /** + * The watermark text + */ + watermarkText?: string; + /** + * The watermark type (text or image) + */ + watermarkType: "text" | "image"; + /** + * The width spacer between watermark elements + */ + widthSpacer?: number; +} +export interface AutoSplitPdfRequest { + /** + * Flag indicating if the duplex mode is active, where the page after the divider also gets removed. + */ + duplexMode?: boolean; +} +export interface BookletImpositionRequest { + /** + * Boolean for if you wish to add border around the pages + */ + addBorder?: boolean; + /** + * Add gutter margin (inner margin for binding) + */ + addGutter?: boolean; + /** + * Generate both front and back sides (double-sided printing) + */ + doubleSided?: boolean; + /** + * For manual duplex: which pass to generate + */ + duplexPass?: "BOTH" | "FIRST" | "SECOND"; + /** + * Flip back sides for short-edge duplex printing (default is long-edge) + */ + flipOnShortEdge?: boolean; + /** + * Gutter margin size in points (used when addGutter is true) + */ + gutterSize?: number; + /** + * The number of pages per side for booklet printing (always 2 for proper booklet). + */ + pagesPerSheet?: 2; + /** + * The spine location for the booklet. + */ + spineLocation?: "LEFT" | "RIGHT"; +} +export interface ConvertCbrToPdfRequest { + /** + * Optimize the output PDF for ebook reading using Ghostscript + */ + optimizeForEbook?: boolean; +} +export interface ConvertCbzToPdfRequest { + /** + * Optimize the output PDF for ebook reading using Ghostscript + */ + optimizeForEbook?: boolean; +} +export interface ConvertEbookToPdfRequest { + /** + * Embed all fonts from the eBook into the generated PDF + */ + embedAllFonts?: true | false; + /** + * Add page numbers to the generated PDF + */ + includePageNumbers?: true | false; + /** + * Add a generated table of contents to the resulting PDF + */ + includeTableOfContents?: true | false; + /** + * Optimize the PDF for eBook reading (smaller file size, better rendering on eInk devices) + */ + optimizeForEbook?: true | false; +} +export type ConvertPdfHtmlRequest = Record; +export type ConvertPdfMarkdownRequest = Record; +export type ConvertPdfTextEditorMetadataRequest = Record; +export interface ConvertPdfTextEditorRequest { + lightweight?: boolean; +} +export interface ConvertPdfToCbrRequest { + /** + * The DPI (Dots Per Inch) for rendering PDF pages as images + */ + dpi: number; +} +export interface ConvertPdfToCbzRequest { + /** + * The DPI (Dots Per Inch) for rendering PDF pages as images + */ + dpi: number; +} +export interface ConvertPdfToEpubRequest { + /** + * Detect headings that look like chapters and insert EPUB page breaks. + */ + detectChapters?: true | false; + /** + * Choose the output format for the ebook. + */ + outputFormat?: "EPUB" | "AZW3"; + /** + * Choose an output profile optimized for the reader device. + */ + targetDevice?: "TABLET_PHONE_IMAGES" | "KINDLE_EINK_TEXT"; +} +export type ConvertPdfXmlRequest = Record; +export interface ConvertToImageRequest { + /** + * The color type of the output image(s) + */ + colorType?: "color" | "greyscale" | "blackwhite"; + /** + * The DPI (dots per inch) for the output image(s) + */ + dpi?: number; + /** + * The output image format + */ + imageFormat?: "png" | "jpeg" | "jpg" | "gif" | "webp"; + /** + * Include annotations such as comments in the output image(s) + */ + includeAnnotations?: boolean; + /** + * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5') + */ + pageNumbers?: string; + /** + * Choose between a single image containing all pages or separate images for each page + */ + singleOrMultiple?: "single" | "multiple"; +} +export interface ConvertToPdfRequest { + /** + * Whether to automatically rotate the images to better fit the PDF page + */ + autoRotate?: boolean; + /** + * The color type of the output image(s) + */ + colorType?: "color" | "greyscale" | "blackwhite"; + /** + * Option to determine how the image will fit onto the page + */ + fitOption?: "fillPage" | "fitDocumentToImage" | "maintainAspectRatio"; +} +export interface CropPdfForm { + /** + * Enable auto-crop to detect and remove white space + */ + autoCrop?: boolean; + /** + * The height of the crop area + */ + height?: number; + /** + * Whether to remove text outside the crop area (keeps images) + */ + removeDataOutsideCrop?: boolean; + /** + * The width of the crop area + */ + width?: number; + /** + * The x-coordinate of the top-left corner of the crop area + */ + x?: number; + /** + * The y-coordinate of the top-left corner of the crop area + */ + y?: number; +} +export interface DeleteAttachmentRequest { + /** + * The name of the attachment to delete + */ + attachmentName: string; +} +export interface EditTableOfContentsRequest { + /** + * Bookmark structure in JSON format + */ + bookmarkData?: string; + /** + * Whether to replace existing bookmarks or append to them + */ + replaceExisting?: boolean; +} +export interface EditTextRequest { + /** + * Ordered list of find/replace operations. Each replaces every occurrence on the selected pages, in order; later operations see the result of earlier ones (so 'foo'->'foos' then 'foos'->'bars' turns 'foo' into 'bars'). + */ + edits: EditTextOperation[]; + /** + * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5') + */ + pageNumbers?: string; + /** + * Whether matches must be whole words (boundaries determined by non-word characters) + */ + wholeWordSearch?: boolean; +} +/** + * Ordered list of find/replace operations. Each replaces every occurrence on the selected pages, in order; later operations see the result of earlier ones (so 'foo'->'foos' then 'foos'->'bars' turns 'foo' into 'bars'). + */ +export interface EditTextOperation { + /** + * The literal text to find. + */ + find: string; + /** + * The replacement text. May be empty to delete the matched text. + */ + replace: string; +} +export interface EmlToPdfRequest { + /** + * Download HTML intermediate file instead of PDF + */ + downloadHtml?: boolean; + /** + * Include CC and BCC recipients in header (if available) + */ + includeAllRecipients?: boolean; + /** + * Include email attachments in the PDF output + */ + includeAttachments?: boolean; + /** + * Maximum attachment size in MB to include (default 10MB, range: 1-100) + */ + maxAttachmentSizeMB?: number; +} +export type ExtractAttachmentsRequest = Record; +export interface ExtractHeaderRequest { + /** + * Flag indicating whether to use the first text as a fallback if no suitable title is found. Defaults to false. + */ + useFirstTextAsFallback?: boolean; +} +export interface ExtractImageScansRequest { + /** + * The angle threshold for the image scan extraction + */ + angleThreshold?: number; + /** + * The border size for the image scan extraction + */ + borderSize?: number; + /** + * The minimum area for the image scan extraction + */ + minArea?: number; + /** + * The minimum contour area for the image scan extraction + */ + minContourArea?: number; + /** + * The tolerance for the image scan extraction + */ + tolerance?: number; +} +export interface FlattenRequest { + /** + * True to flatten only the forms, false to flatten full PDF (Convert page to image) + */ + flattenOnlyForms?: boolean; + /** + * Optional DPI for page rendering when flattening the full document. + */ + renderDpi?: number; +} +export interface GeneralExtractBookmarksRequest { + file: string; +} +export type GeneralFile = Record; +export type GeneralPdfToSinglePageRequest = Record; +export type GeneralRemoveImagePdfRequest = Record; +export interface HTMLToPdfRequest { + /** + * Zoom level for displaying the website. Default is '1'. + */ + zoom?: number; +} +export type ListAttachmentsRequest = Record; +export interface ManualRedactPdfRequest { + /** + * Convert the redacted PDF to an image + */ + convertPDFToImage?: boolean; + /** + * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5') + */ + pageNumbers?: string; + /** + * The color used to fully redact certain pages + */ + pageRedactionColor?: string; + /** + * A list of areas that should be redacted + */ + redactions: RedactionArea[]; +} +/** + * A list of areas that should be redacted + */ +export interface RedactionArea { + /** + * The color used to redact the specified area. + */ + color?: string; + /** + * The height of the area to be redacted. + */ + height?: number; + /** + * The page on which the area should be redacted. + */ + page?: number; + /** + * The width of the area to be redacted. + */ + width?: number; + /** + * The left edge point of the area to be redacted. + */ + x?: number; + /** + * The top edge point of the area to be redacted. + */ + y?: number; +} +export interface MergeMultiplePagesRequest { + /** + * Boolean for if you wish to add border around the pages + */ + addBorder?: boolean; + /** + * The arrangement of pages on the sheet: BY_ROWS fills pages row by row, while BY_COLUMNS fills pages column by column. + */ + arrangement?: "BY_ROWS" | "BY_COLUMNS"; + /** + * Border width (in points) to apply around each page when merging + */ + borderWidth?: number; + /** + * Bottom margin (in points) to apply to the output pages when merging + */ + bottomMargin?: number; + /** + * Number of columns + */ + cols?: number; + /** + * Inner margin (in points) to apply around each page when merging + */ + innerMargin?: number; + /** + * Left margin (in points) to apply to the output pages when merging + */ + leftMargin?: number; + /** + * Input mode: DEFAULT uses pagesPerSheet; CUSTOM uses explicit cols x rows. + */ + mode?: "DEFAULT" | "CUSTOM"; + /** + * The orientation of the output PDF pages + */ + orientation?: "PORTRAIT" | "LANDSCAPE"; + /** + * The number of pages to fit onto a single sheet in the output PDF. + */ + pagesPerSheet?: 2 | 4 | 9 | 16; + /** + * The direction in which pages are arranged on the sheet: LTR (left-to-right) or RTL (right-to-left). + */ + readingDirection?: "LTR" | "RTL"; + /** + * Right margin (in points) to apply to the output pages when merging + */ + rightMargin?: number; + /** + * Number of rows + */ + rows?: number; + /** + * Top margin (in points) to apply to the output pages when merging + */ + topMargin?: number; +} +export interface MergePdfsRequest { + /** + * JSON array of client-provided IDs for each uploaded file (same order as fileInput) + */ + clientFileIds?: string; + fileOrder?: string; + /** + * Flag indicating whether to generate a table of contents for the merged PDF. If true, a table of contents will be created using the input filenames as chapter names. + */ + generateToc?: boolean; + /** + * Flag indicating whether to remove certification signatures from the merged PDF. If true, all certification signatures will be removed from the final merged document. + */ + removeCertSign?: boolean; + /** + * The type of sorting to be applied on the input files before merging. + */ + sortType?: + | "orderProvided" + | "byFileName" + | "byDateModified" + | "byDateCreated" + | "byPDFTitle"; +} +export interface MetadataRequest { + /** + * Map list of key and value of custom parameters. Note these must start with customKey and customValue if they are non-standard + */ + allRequestParams?: { + /** + * Map list of key and value of custom parameters. Note these must start with customKey and customValue if they are non-standard + */ + [k: string]: string | undefined; + }; + /** + * The author of the document + */ + author?: string; + /** + * The creation date of the document (format: yyyy/MM/dd HH:mm:ss) + */ + creationDate?: string; + /** + * The creator of the document + */ + creator?: string; + /** + * Delete all metadata if set to true + */ + deleteAll?: boolean; + /** + * The keywords for the document + */ + keywords?: string; + /** + * The modification date of the document (format: yyyy/MM/dd HH:mm:ss) + */ + modificationDate?: string; + /** + * The producer of the document + */ + producer?: string; + /** + * The subject of the document + */ + subject?: string; + /** + * The title of the document + */ + title?: string; + /** + * The trapped status of the document + */ + trapped?: "True" | "False" | "Unknown"; +} +export type MiscDecompressPdfRequest = Record; +export type MiscRepairRequest = Record; +export type MiscShowJavascriptRequest = Record; +export type MiscUnlockPdfFormsRequest = Record; +export interface OptimizePdfRequest { + /** + * The expected output size, e.g. '100MB', '25KB', etc. + */ + expectedOutputSize?: string; + /** + * Whether to convert the PDF to grayscale. Default is false. + */ + grayscale?: boolean; + /** + * Whether to convert images to high-contrast line art using ImageMagick. Default is false. + */ + lineArt?: boolean; + /** + * Edge detection strength to use for line art conversion (1-3). This maps to ImageMagick's -edge radius. + */ + lineArtEdgeLevel?: 1 | 2 | 3; + /** + * Threshold to use for line art conversion (0-100). + */ + lineArtThreshold?: number; + /** + * Whether to linearize the PDF for faster web viewing. Default is false. + */ + linearize?: boolean; + /** + * Whether to normalize the PDF content for better compatibility. Default is false. + */ + normalize?: boolean; + /** + * The level of optimization to apply to the PDF file. Higher values indicate greater compression but may reduce quality. + */ + optimizeLevel: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; +} +export interface OverlayImageRequest { + /** + * Whether to overlay the image onto every page of the PDF. + */ + everyPage?: boolean; + imageFile: string; + /** + * The x-coordinate at which to place the top-left corner of the image. + */ + x?: number; + /** + * The y-coordinate at which to place the top-left corner of the image. + */ + y?: number; +} +export interface OverlayPdfsRequest { + /** + * 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. + */ + counts?: number[]; + /** + * 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. + */ + overlayFiles: string[]; + /** + * The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts + */ + overlayMode: + | "SequentialOverlay" + | "InterleavedOverlay" + | "FixedRepeatOverlay"; + /** + * Overlay position 0 is Foregound, 1 is Background + */ + overlayPosition: 0 | 1; +} +export interface PDFExtractImagesRequest { + /** + * The output image format e.g., 'png', 'jpeg', or 'gif' + */ + format?: "png" | "jpeg" | "gif"; +} +export interface PDFPasswordRequest { + /** + * The password of the PDF file + */ + password?: string; +} +export type PDFVerificationRequest = Record; +export interface PDFWithPageNums { + /** + * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5') + */ + pageNumbers?: string; +} +export interface PdfToPdfARequest { + /** + * The output format type (PDF/A or PDF/X) + */ + outputFormat: + | "pdfa" + | "pdfa-1" + | "pdfa-2" + | "pdfa-2b" + | "pdfa-3" + | "pdfa-3b" + | "pdfx"; + /** + * If true, the conversion will fail if the output is not perfectly compliant + */ + strict?: boolean; +} +export interface PdfToPresentationRequest { + /** + * The output Presentation format + */ + outputFormat: "ppt" | "pptx" | "odp"; +} +export interface PdfToTextOrRTFRequest { + /** + * The output Text or RTF format + */ + outputFormat: "rtf" | "txt"; +} +export interface PdfToWordRequest { + /** + * The output Word document format + */ + outputFormat: "doc" | "docx" | "odt"; +} +export interface PdfVectorExportRequest { + /** + * Target vector format extension + */ + outputFormat?: "eps" | "ps" | "pcl" | "xps"; + /** + * Apply Ghostscript prepress settings + */ + prepress?: true | false; +} +export interface Pkcs11CertificatesRequest { + libraryPath?: string; + pin?: string; + slot?: number; +} +export interface PosterPdfRequest { + /** + * Target page size for output chunks (e.g., 'A4', 'Letter', 'A3') + */ + pageSize: "A4" | "Letter" | "A3" | "A5" | "Legal" | "Tabloid"; + /** + * Split right-to-left instead of left-to-right + */ + rightToLeft?: boolean; + /** + * Horizontal decimation factor (how many columns to split into) + */ + xFactor?: number; + /** + * Vertical decimation factor (how many rows to split into) + */ + yFactor?: number; +} +export interface ProcessPdfWithOcrRequest { + /** + * Clean the input file if set to true + */ + clean?: boolean; + /** + * Clean the final output if set to true + */ + cleanFinal?: boolean; + /** + * Deskew the input file if set to true + */ + deskew?: boolean; + /** + * List of languages to use in OCR processing, e.g., 'eng', 'deu' + */ + languages?: string[]; + /** + * Specify the OCR render type, either 'hocr' or 'sandwich' + */ + ocrRenderType?: "hocr" | "sandwich"; + /** + * Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal' + */ + ocrType: "skip-text" | "force-ocr" | "Normal"; + /** + * Remove images from the output PDF if set to true + */ + removeImagesAfter?: boolean; + /** + * Include OCR text in a sidecar text file if set to true + */ + sidecar?: boolean; +} +export interface RearrangePagesRequest { + /** + * The custom mode for page rearrangement. Valid values are: + * CUSTOM: Uses order defined in PageNums DUPLICATE: Duplicate pages n times (if Page order defined as 4, then duplicates each page 4 times)REVERSE_ORDER: Reverses the order of all pages. + * DUPLEX_SORT: Sorts pages as if all fronts were scanned then all backs in reverse (1, n, 2, n-1, ...). BOOKLET_SORT: Arranges pages for booklet printing (last, first, second, second last, ...). + * ODD_EVEN_SPLIT: Splits and arranges pages into odd and even numbered pages. + * REMOVE_FIRST: Removes the first page. + * REMOVE_LAST: Removes the last page. + * REMOVE_FIRST_AND_LAST: Removes both the first and the last pages. + * + */ + customMode?: + | "CUSTOM" + | "REVERSE_ORDER" + | "DUPLEX_SORT" + | "BOOKLET_SORT" + | "SIDE_STITCH_BOOKLET_SORT" + | "ODD_EVEN_SPLIT" + | "REMOVE_FIRST" + | "REMOVE_LAST" + | "REMOVE_FIRST_AND_LAST" + | "DUPLICATE"; + /** + * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5') + */ + pageNumbers?: string; +} +export interface RedactExecuteRequest { + /** + * Rectangular areas to black out, each defined by a page number and bounding box coordinates. + */ + imageBoxes?: ImageBox[]; + /** + * Text ranges to redact by specifying a start and end anchor phrase. All content between the two phrases (inclusive) is redacted. Anchors work best when short and unique. They must appear verbatim in the document. + */ + ranges?: TextRange[]; + /** + * 1-indexed page numbers to redact all detected images from. Pass an empty list to redact images from every page. Omit or pass null to skip image redaction entirely. + */ + redactImagePages?: number[]; + /** + * Regex patterns to match and redact. Each match anywhere in the document is blacked out. Uses Java/PCRE regex syntax. Well-suited for strings that follow known patterns, like phone numbers, email addresses, national ID numbers, or dates (which can appear with different separators, optional country codes, etc.). For fixed known strings such as names, use textValues instead. + */ + regexPatterns?: string[]; + style?: RedactStyle; + /** + * Exact strings to find and black out. One entry per phrase to redact. Best for known names, identifiers, and specific text found in the document. + */ + textValues?: string[]; + /** + * 1-indexed page numbers to wipe entirely (all content removed from those pages). + */ + wipePages?: number[]; +} +/** + * Rectangular areas to black out, each defined by a page number and bounding box coordinates. + */ +export interface ImageBox { + /** + * 0-indexed page number (first page = 0). + */ + pageIndex: number; + /** + * Left x coordinate of the redaction rectangle in PDF user-space points. + */ + x1: number; + /** + * Right x coordinate of the redaction rectangle in PDF user-space points. + */ + x2: number; + /** + * Top y coordinate of the redaction rectangle in PDF user-space points. + */ + y1: number; + /** + * Bottom y coordinate of the redaction rectangle in PDF user-space points. + */ + y2: number; +} +/** + * Text ranges to redact by specifying a start and end anchor phrase. All content between the two phrases (inclusive) is redacted. Anchors work best when short and unique. They must appear verbatim in the document. + */ +export interface TextRange { + /** + * A short, distinctive phrase (5–15 words) that marks where redaction ends (inclusive). Must appear verbatim in the document. Shorter phrases match more reliably. + */ + endString: string; + /** + * A short, distinctive phrase (5–15 words) that marks where redaction begins (inclusive). Must appear verbatim in the document — e.g. a section heading or a unique sentence fragment. + */ + startString: string; +} +/** + * Redaction style options + */ +export interface RedactStyle { + /** + * Hex redaction box color + */ + color?: string; + /** + * Rasterize output to prevent text extraction + */ + convertToImage?: boolean; + /** + * Extra padding around each box in points + */ + padding?: number; + /** + * Execution strategy hint for the redaction pipeline + */ + strategy?: "AUTO" | "OVERLAY_ONLY" | "IMAGE_FINALIZE"; +} +export interface RedactPdfRequest { + /** + * Convert the redacted PDF to an image + */ + convertPDFToImage?: boolean; + /** + * Custom padding for redaction + */ + customPadding: number; + /** + * List of text to redact from the PDF + */ + listOfText?: string; + /** + * The color for redaction + */ + redactColor?: string; + /** + * Whether to use regex for the listOfText + */ + useRegex?: boolean; + /** + * Whether to use whole word search + */ + wholeWordSearch?: boolean; +} +export interface RemoveBlankPagesRequest { + /** + * The threshold value to determine blank pages + */ + threshold?: number; + /** + * The percentage of white color on a page to consider it as blank + */ + whitePercent?: number; +} +export interface RenameAttachmentRequest { + /** + * The current name of the attachment to rename + */ + attachmentName: string; + /** + * The new name for the attachment + */ + newName: string; +} +export interface ReplaceAndInvertColorRequest { + /** + * If CUSTOM_COLOR option selected, then pick the custom color for background. Expected color value should be 24bit decimal value of a color + */ + backGroundColor?: string; + /** + * If HIGH_CONTRAST_COLOR option selected, then pick the default color option for text and background. + */ + highContrastColorCombination?: + | "WHITE_TEXT_ON_BLACK" + | "BLACK_TEXT_ON_WHITE" + | "YELLOW_TEXT_ON_BLACK" + | "GREEN_TEXT_ON_BLACK"; + /** + * Replace and Invert color options of a pdf. + */ + replaceAndInvertOption?: + | "HIGH_CONTRAST_COLOR" + | "CUSTOM_COLOR" + | "FULL_INVERSION" + | "COLOR_SPACE_CONVERSION"; + /** + * If CUSTOM_COLOR option selected, then pick the custom color for text. Expected color value should be 24bit decimal value of a color + */ + textColor?: string; +} +export interface RotatePDFRequest { + /** + * The clockwise angle by which to rotate all pages in the PDF file. Must be a multiple of 90. + */ + angle: 0 | 90 | 180 | 270; +} +export interface SanitizePdfRequest { + /** + * Remove embedded files from the PDF + */ + removeEmbeddedFiles?: boolean; + /** + * Remove fonts from the PDF + */ + removeFonts?: boolean; + /** + * Remove JavaScript actions from the PDF + */ + removeJavaScript?: boolean; + /** + * Remove links from the PDF + */ + removeLinks?: boolean; + /** + * Remove document info metadata from the PDF + */ + removeMetadata?: boolean; + /** + * Remove XMP metadata from the PDF + */ + removeXMPMetadata?: boolean; +} +export interface ScalePagesRequest { + /** + * Orientation to apply to the target page size. Ignored when pageSize is KEEP. + */ + orientation?: "PORTRAIT" | "LANDSCAPE"; + /** + * The scale of pages in the output PDF. Acceptable values are A0-A6, LETTER, LEGAL, KEEP. + */ + pageSize: + | "A0" + | "A1" + | "A2" + | "A3" + | "A4" + | "A5" + | "A6" + | "LETTER" + | "LEGAL" + | "KEEP"; + /** + * The scale of the content on the pages of the output PDF. Acceptable values are floats. + */ + scaleFactor?: number; +} +export interface ScannerEffectRequest { + /** + * Whether advanced settings are enabled + */ + advancedEnabled?: boolean; + /** + * Blur amount (0 = none, higher = more blur) + */ + blur?: number; + /** + * Border thickness in pixels + */ + border?: number; + /** + * Brightness multiplier (1.0 = no change) + */ + brightness?: number; + /** + * Colorspace for output image + */ + colorspace?: "grayscale" | "color"; + /** + * Contrast multiplier (1.0 = no change) + */ + contrast?: number; + /** + * Noise amount (0 = none, higher = more noise) + */ + noise?: number; + /** + * Scan quality preset + */ + quality: "low" | "medium" | "high"; + /** + * Rendering resolution in DPI + */ + resolution?: number; + /** + * Base rotation in degrees + */ + rotate?: number; + /** + * Random rotation variance in degrees + */ + rotateVariance?: number; + /** + * Rotation preset + */ + rotation: "none" | "slight" | "moderate" | "severe"; + rotationValue?: number; + /** + * Simulate yellowed paper + */ + yellowish?: boolean; +} +export interface SecurityCertSignSessionsRequest { + file: string; + request?: WorkflowCreationRequest; +} +export interface WorkflowCreationRequest { + documentName?: string; + dueDate?: string; + message?: string; + ownerEmail?: string; + participantEmails?: string[]; + participantUserIds?: number[]; + workflowMetadata?: string; + workflowType?: "SIGNING" | "REVIEW" | "APPROVAL"; +} +export interface SecurityCertSignValidateCertificateRequest { + certType: string; + jksFile?: string; + p12File?: string; + password?: string; +} +export type SecurityGetInfoOnPdfRequest = Record; +export type SecurityRemoveCertSignRequest = Record; +export interface SignPDFWithCertRequest { + /** + * The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates. + */ + alias?: string; + certFile?: string; + /** + * The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app. + */ + certType: + | "PEM" + | "PKCS12" + | "PFX" + | "JKS" + | "SERVER" + | "WINDOWS_STORE" + | "PKCS11"; + jksFile?: string; + /** + * The location where the PDF is signed + */ + location?: string; + /** + * The name of the signer + */ + name?: string; + p12File?: string; + /** + * The page number where the signature should be visible. This is required if showSignature is set to true + */ + pageNumber?: number; + /** + * The password for the keystore / private key, or the token PIN for PKCS11 + */ + password?: string; + /** + * 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. + */ + pkcs11LibraryPath?: string; + /** + * Optional PKCS#11 slot index. When omitted the first slot with a token is used. + */ + pkcs11Slot?: number; + privateKeyFile?: string; + /** + * The reason for signing the PDF + */ + reason?: string; + /** + * Whether to visually show a signature logo along with the signature + */ + showLogo?: boolean; + /** + * Whether to visually show the signature in the PDF file + */ + showSignature?: boolean; +} +export interface SignatureValidationRequest { + certFile?: string; +} +export interface SplitPagesRequest { + /** + * Split points - page numbers after which the PDF will be cut. For example, `"2"` produces two documents (pages 1-2 and pages 3+); `"2,5"` produces three (pages 1-2, 3-5, 6+). Supports ranges (e.g. `"1,3,5-9"` splits after pages 1, 3, 5, 6, 7, 8, 9, yielding 8 documents), `"all"` (split after every page), or functions like `"2n+1"`, `"3n"`, `"6n-5"`. + */ + pageNumbers?: string; +} +export interface SplitPdfByChaptersRequest { + /** + * Whether to allow duplicates or not + */ + allowDuplicates?: boolean; + /** + * Maximum bookmark level required + */ + bookmarkLevel?: number; + /** + * Whether to include Metadata or not + */ + includeMetadata?: boolean; +} +export interface SplitPdfBySectionsRequest { + /** + * Number of horizontal divisions for each PDF page + */ + horizontalDivisions?: number; + /** + * Merge the split documents into a single PDF + */ + merge?: boolean; + /** + * Pages to be split by section + */ + pageNumbers?: string; + /** + * Modes for page split. Valid values are: + * SPLIT_ALL_EXCEPT_FIRST_AND_LAST: Splits all except the first and the last pages. + * SPLIT_ALL_EXCEPT_FIRST: Splits all except the first page. + * SPLIT_ALL_EXCEPT_LAST: Splits all except the last page. + * SPLIT_ALL: Splits all pages. + * CUSTOM: Custom split. + * + */ + splitMode?: + | "CUSTOM" + | "SPLIT_ALL_EXCEPT_FIRST_AND_LAST" + | "SPLIT_ALL_EXCEPT_FIRST" + | "SPLIT_ALL_EXCEPT_LAST" + | "SPLIT_ALL"; + /** + * Number of vertical divisions for each PDF page + */ + verticalDivisions?: number; +} +export interface SplitPdfBySizeOrCountRequest { + /** + * Determines the type of split: 0 for size, 1 for page count, 2 for document count + */ + splitType?: number; + /** + * Value for split: size in MB (e.g., '10MB') or number of pages (e.g., '5') + */ + splitValue?: string; +} +export interface SvgToPdfRequest { + /** + * Whether to combine all SVG files into a single PDF (each SVG as a separate page) or create separate PDF files for each SVG. + */ + combineIntoSinglePdf?: boolean; +} +export interface TimestampPdfRequest { + /** + * URL of the RFC 3161 Time Stamp Authority (TSA) server. Must be one of the built-in presets (DigiCert, Sectigo, SSL.com, FreeTSA, MeSign) or an admin-configured URL in settings.yml (security.timestamp.customTsaUrls). If omitted, the server default is used. + */ + tsaUrl?: string; +} +export interface UrlToPdfRequest { + /** + * The input URL to be converted to a PDF file + */ + urlInput: string; +} + +/** Endpoint path for a generated tool operation (the operation identity across languages). */ +export type ToolEndpoint = + | "/api/v1/convert/cbr/pdf" + | "/api/v1/convert/cbz/pdf" + | "/api/v1/convert/ebook/pdf" + | "/api/v1/convert/eml/pdf" + | "/api/v1/convert/file/pdf" + | "/api/v1/convert/html/pdf" + | "/api/v1/convert/img/pdf" + | "/api/v1/convert/markdown/pdf" + | "/api/v1/convert/pdf/cbr" + | "/api/v1/convert/pdf/cbz" + | "/api/v1/convert/pdf/csv" + | "/api/v1/convert/pdf/epub" + | "/api/v1/convert/pdf/html" + | "/api/v1/convert/pdf/img" + | "/api/v1/convert/pdf/markdown" + | "/api/v1/convert/pdf/pdfa" + | "/api/v1/convert/pdf/presentation" + | "/api/v1/convert/pdf/text" + | "/api/v1/convert/pdf/text-editor" + | "/api/v1/convert/pdf/text-editor/metadata" + | "/api/v1/convert/pdf/vector" + | "/api/v1/convert/pdf/word" + | "/api/v1/convert/pdf/xlsx" + | "/api/v1/convert/pdf/xml" + | "/api/v1/convert/svg/pdf" + | "/api/v1/convert/text-editor/pdf" + | "/api/v1/convert/url/pdf" + | "/api/v1/convert/vector/pdf" + | "/api/v1/general/booklet-imposition" + | "/api/v1/general/crop" + | "/api/v1/general/edit-table-of-contents" + | "/api/v1/general/edit-text" + | "/api/v1/general/extract-bookmarks" + | "/api/v1/general/merge-pdfs" + | "/api/v1/general/multi-page-layout" + | "/api/v1/general/overlay-pdfs" + | "/api/v1/general/pdf-to-single-page" + | "/api/v1/general/rearrange-pages" + | "/api/v1/general/remove-image-pdf" + | "/api/v1/general/remove-pages" + | "/api/v1/general/rotate-pdf" + | "/api/v1/general/scale-pages" + | "/api/v1/general/split-by-size-or-count" + | "/api/v1/general/split-for-poster-print" + | "/api/v1/general/split-pages" + | "/api/v1/general/split-pdf-by-chapters" + | "/api/v1/general/split-pdf-by-sections" + | "/api/v1/misc/add-attachments" + | "/api/v1/misc/add-comments" + | "/api/v1/misc/add-image" + | "/api/v1/misc/add-page-numbers" + | "/api/v1/misc/add-stamp" + | "/api/v1/misc/auto-rename" + | "/api/v1/misc/auto-split-pdf" + | "/api/v1/misc/compress-pdf" + | "/api/v1/misc/decompress-pdf" + | "/api/v1/misc/delete-attachment" + | "/api/v1/misc/extract-attachments" + | "/api/v1/misc/extract-image-scans" + | "/api/v1/misc/extract-images" + | "/api/v1/misc/flatten" + | "/api/v1/misc/list-attachments" + | "/api/v1/misc/ocr-pdf" + | "/api/v1/misc/remove-blanks" + | "/api/v1/misc/rename-attachment" + | "/api/v1/misc/repair" + | "/api/v1/misc/replace-invert-pdf" + | "/api/v1/misc/scanner-effect" + | "/api/v1/misc/show-javascript" + | "/api/v1/misc/unlock-pdf-forms" + | "/api/v1/misc/update-metadata" + | "/api/v1/security/add-password" + | "/api/v1/security/add-watermark" + | "/api/v1/security/auto-redact" + | "/api/v1/security/cert-sign" + | "/api/v1/security/cert-sign/hardware/pkcs11-certificates" + | "/api/v1/security/cert-sign/sessions" + | "/api/v1/security/cert-sign/validate-certificate" + | "/api/v1/security/get-info-on-pdf" + | "/api/v1/security/redact" + | "/api/v1/security/redact-execute" + | "/api/v1/security/remove-cert-sign" + | "/api/v1/security/remove-password" + | "/api/v1/security/sanitize-pdf" + | "/api/v1/security/timestamp-pdf" + | "/api/v1/security/validate-signature" + | "/api/v1/security/verify-pdf"; + +/** Backend request-parameter model for each tool endpoint. */ +export interface ToolApiParams { + "/api/v1/convert/cbr/pdf": ConvertCbrToPdfRequest; + "/api/v1/convert/cbz/pdf": ConvertCbzToPdfRequest; + "/api/v1/convert/ebook/pdf": ConvertEbookToPdfRequest; + "/api/v1/convert/eml/pdf": EmlToPdfRequest; + "/api/v1/convert/file/pdf": GeneralFile; + "/api/v1/convert/html/pdf": HTMLToPdfRequest; + "/api/v1/convert/img/pdf": ConvertToPdfRequest; + "/api/v1/convert/markdown/pdf": GeneralFile; + "/api/v1/convert/pdf/cbr": ConvertPdfToCbrRequest; + "/api/v1/convert/pdf/cbz": ConvertPdfToCbzRequest; + "/api/v1/convert/pdf/csv": PDFWithPageNums; + "/api/v1/convert/pdf/epub": ConvertPdfToEpubRequest; + "/api/v1/convert/pdf/html": ConvertPdfHtmlRequest; + "/api/v1/convert/pdf/img": ConvertToImageRequest; + "/api/v1/convert/pdf/markdown": ConvertPdfMarkdownRequest; + "/api/v1/convert/pdf/pdfa": PdfToPdfARequest; + "/api/v1/convert/pdf/presentation": PdfToPresentationRequest; + "/api/v1/convert/pdf/text": PdfToTextOrRTFRequest; + "/api/v1/convert/pdf/text-editor": ConvertPdfTextEditorRequest; + "/api/v1/convert/pdf/text-editor/metadata": ConvertPdfTextEditorMetadataRequest; + "/api/v1/convert/pdf/vector": PdfVectorExportRequest; + "/api/v1/convert/pdf/word": PdfToWordRequest; + "/api/v1/convert/pdf/xlsx": PDFWithPageNums; + "/api/v1/convert/pdf/xml": ConvertPdfXmlRequest; + "/api/v1/convert/svg/pdf": SvgToPdfRequest; + "/api/v1/convert/text-editor/pdf": GeneralFile; + "/api/v1/convert/url/pdf": UrlToPdfRequest; + "/api/v1/convert/vector/pdf": PdfVectorExportRequest; + "/api/v1/general/booklet-imposition": BookletImpositionRequest; + "/api/v1/general/crop": CropPdfForm; + "/api/v1/general/edit-table-of-contents": EditTableOfContentsRequest; + "/api/v1/general/edit-text": EditTextRequest; + "/api/v1/general/extract-bookmarks": GeneralExtractBookmarksRequest; + "/api/v1/general/merge-pdfs": MergePdfsRequest; + "/api/v1/general/multi-page-layout": MergeMultiplePagesRequest; + "/api/v1/general/overlay-pdfs": OverlayPdfsRequest; + "/api/v1/general/pdf-to-single-page": GeneralPdfToSinglePageRequest; + "/api/v1/general/rearrange-pages": RearrangePagesRequest; + "/api/v1/general/remove-image-pdf": GeneralRemoveImagePdfRequest; + "/api/v1/general/remove-pages": PDFWithPageNums; + "/api/v1/general/rotate-pdf": RotatePDFRequest; + "/api/v1/general/scale-pages": ScalePagesRequest; + "/api/v1/general/split-by-size-or-count": SplitPdfBySizeOrCountRequest; + "/api/v1/general/split-for-poster-print": PosterPdfRequest; + "/api/v1/general/split-pages": SplitPagesRequest; + "/api/v1/general/split-pdf-by-chapters": SplitPdfByChaptersRequest; + "/api/v1/general/split-pdf-by-sections": SplitPdfBySectionsRequest; + "/api/v1/misc/add-attachments": AddAttachmentRequest; + "/api/v1/misc/add-comments": AddCommentsRequest; + "/api/v1/misc/add-image": OverlayImageRequest; + "/api/v1/misc/add-page-numbers": AddPageNumbersRequest; + "/api/v1/misc/add-stamp": AddStampRequest; + "/api/v1/misc/auto-rename": ExtractHeaderRequest; + "/api/v1/misc/auto-split-pdf": AutoSplitPdfRequest; + "/api/v1/misc/compress-pdf": OptimizePdfRequest; + "/api/v1/misc/decompress-pdf": MiscDecompressPdfRequest; + "/api/v1/misc/delete-attachment": DeleteAttachmentRequest; + "/api/v1/misc/extract-attachments": ExtractAttachmentsRequest; + "/api/v1/misc/extract-image-scans": ExtractImageScansRequest; + "/api/v1/misc/extract-images": PDFExtractImagesRequest; + "/api/v1/misc/flatten": FlattenRequest; + "/api/v1/misc/list-attachments": ListAttachmentsRequest; + "/api/v1/misc/ocr-pdf": ProcessPdfWithOcrRequest; + "/api/v1/misc/remove-blanks": RemoveBlankPagesRequest; + "/api/v1/misc/rename-attachment": RenameAttachmentRequest; + "/api/v1/misc/repair": MiscRepairRequest; + "/api/v1/misc/replace-invert-pdf": ReplaceAndInvertColorRequest; + "/api/v1/misc/scanner-effect": ScannerEffectRequest; + "/api/v1/misc/show-javascript": MiscShowJavascriptRequest; + "/api/v1/misc/unlock-pdf-forms": MiscUnlockPdfFormsRequest; + "/api/v1/misc/update-metadata": MetadataRequest; + "/api/v1/security/add-password": AddPasswordRequest; + "/api/v1/security/add-watermark": AddWatermarkRequest; + "/api/v1/security/auto-redact": RedactPdfRequest; + "/api/v1/security/cert-sign": SignPDFWithCertRequest; + "/api/v1/security/cert-sign/hardware/pkcs11-certificates": Pkcs11CertificatesRequest; + "/api/v1/security/cert-sign/sessions": SecurityCertSignSessionsRequest; + "/api/v1/security/cert-sign/validate-certificate": SecurityCertSignValidateCertificateRequest; + "/api/v1/security/get-info-on-pdf": SecurityGetInfoOnPdfRequest; + "/api/v1/security/redact": ManualRedactPdfRequest; + "/api/v1/security/redact-execute": RedactExecuteRequest; + "/api/v1/security/remove-cert-sign": SecurityRemoveCertSignRequest; + "/api/v1/security/remove-password": PDFPasswordRequest; + "/api/v1/security/sanitize-pdf": SanitizePdfRequest; + "/api/v1/security/timestamp-pdf": TimestampPdfRequest; + "/api/v1/security/validate-signature": SignatureValidationRequest; + "/api/v1/security/verify-pdf": PDFVerificationRequest; +} + +/** Every generated tool endpoint, for iteration. */ +export const TOOL_ENDPOINTS = [ + "/api/v1/convert/cbr/pdf", + "/api/v1/convert/cbz/pdf", + "/api/v1/convert/ebook/pdf", + "/api/v1/convert/eml/pdf", + "/api/v1/convert/file/pdf", + "/api/v1/convert/html/pdf", + "/api/v1/convert/img/pdf", + "/api/v1/convert/markdown/pdf", + "/api/v1/convert/pdf/cbr", + "/api/v1/convert/pdf/cbz", + "/api/v1/convert/pdf/csv", + "/api/v1/convert/pdf/epub", + "/api/v1/convert/pdf/html", + "/api/v1/convert/pdf/img", + "/api/v1/convert/pdf/markdown", + "/api/v1/convert/pdf/pdfa", + "/api/v1/convert/pdf/presentation", + "/api/v1/convert/pdf/text", + "/api/v1/convert/pdf/text-editor", + "/api/v1/convert/pdf/text-editor/metadata", + "/api/v1/convert/pdf/vector", + "/api/v1/convert/pdf/word", + "/api/v1/convert/pdf/xlsx", + "/api/v1/convert/pdf/xml", + "/api/v1/convert/svg/pdf", + "/api/v1/convert/text-editor/pdf", + "/api/v1/convert/url/pdf", + "/api/v1/convert/vector/pdf", + "/api/v1/general/booklet-imposition", + "/api/v1/general/crop", + "/api/v1/general/edit-table-of-contents", + "/api/v1/general/edit-text", + "/api/v1/general/extract-bookmarks", + "/api/v1/general/merge-pdfs", + "/api/v1/general/multi-page-layout", + "/api/v1/general/overlay-pdfs", + "/api/v1/general/pdf-to-single-page", + "/api/v1/general/rearrange-pages", + "/api/v1/general/remove-image-pdf", + "/api/v1/general/remove-pages", + "/api/v1/general/rotate-pdf", + "/api/v1/general/scale-pages", + "/api/v1/general/split-by-size-or-count", + "/api/v1/general/split-for-poster-print", + "/api/v1/general/split-pages", + "/api/v1/general/split-pdf-by-chapters", + "/api/v1/general/split-pdf-by-sections", + "/api/v1/misc/add-attachments", + "/api/v1/misc/add-comments", + "/api/v1/misc/add-image", + "/api/v1/misc/add-page-numbers", + "/api/v1/misc/add-stamp", + "/api/v1/misc/auto-rename", + "/api/v1/misc/auto-split-pdf", + "/api/v1/misc/compress-pdf", + "/api/v1/misc/decompress-pdf", + "/api/v1/misc/delete-attachment", + "/api/v1/misc/extract-attachments", + "/api/v1/misc/extract-image-scans", + "/api/v1/misc/extract-images", + "/api/v1/misc/flatten", + "/api/v1/misc/list-attachments", + "/api/v1/misc/ocr-pdf", + "/api/v1/misc/remove-blanks", + "/api/v1/misc/rename-attachment", + "/api/v1/misc/repair", + "/api/v1/misc/replace-invert-pdf", + "/api/v1/misc/scanner-effect", + "/api/v1/misc/show-javascript", + "/api/v1/misc/unlock-pdf-forms", + "/api/v1/misc/update-metadata", + "/api/v1/security/add-password", + "/api/v1/security/add-watermark", + "/api/v1/security/auto-redact", + "/api/v1/security/cert-sign", + "/api/v1/security/cert-sign/hardware/pkcs11-certificates", + "/api/v1/security/cert-sign/sessions", + "/api/v1/security/cert-sign/validate-certificate", + "/api/v1/security/get-info-on-pdf", + "/api/v1/security/redact", + "/api/v1/security/redact-execute", + "/api/v1/security/remove-cert-sign", + "/api/v1/security/remove-password", + "/api/v1/security/sanitize-pdf", + "/api/v1/security/timestamp-pdf", + "/api/v1/security/validate-signature", + "/api/v1/security/verify-pdf", +] as const satisfies readonly ToolEndpoint[]; + +/** Union of every generated tool request model. */ +export type ToolApiRequest = ToolApiParams[ToolEndpoint]; diff --git a/frontend/editor/src/core/utils/automationConverter.test.ts b/frontend/editor/src/core/utils/automationConverter.test.ts index 88e313c604..d61d61f08e 100644 --- a/frontend/editor/src/core/utils/automationConverter.test.ts +++ b/frontend/editor/src/core/utils/automationConverter.test.ts @@ -120,6 +120,35 @@ describe("automationConverter", () => { const config = convertToFolderScanningConfig(automation, registry); expect(config.pipeline[0].operation).toBe("unknownTool"); }); + + test("preserves frontend params on export, even for a tool with a toApiParams mapper", () => { + // The folder-scan export keeps frontend param shape; toApiParams runs at + // execution time, not here. A tool with a mapper still exports its UI + // field name (compressionLevel), not the backend one (optimizeLevel). + const withMapper = { + ...registry, + compress: { + operationConfig: { + endpoint: "/api/v1/misc/compress-pdf", + toApiParams: (p: Record) => ({ + optimizeLevel: p.compressionLevel, + }), + }, + }, + } as unknown as Partial; + const automation: AutomationConfig = { + ...sampleAutomation, + operations: [ + { operation: "compress", parameters: { compressionLevel: 9 } }, + ], + }; + const config = convertToFolderScanningConfig(automation, withMapper); + // The UI field name and value are preserved as-is (not optimizeLevel). + expect(config.pipeline[0].parameters).toEqual({ + compressionLevel: 9, + fileInput: "automated", + }); + }); }); describe("detectAutomationFormat", () => { @@ -212,6 +241,72 @@ describe("automationConverter", () => { }); }); + test("round-trips frontend params losslessly, even for a tool with a mapper", () => { + // A value set in the UI survives an export then import unchanged. Because + // the export keeps frontend shape, a tool with a toApiParams mapper + // round-trips just like one without. + const withMapper = { + ...registry, + compress: { + operationConfig: { + endpoint: "/api/v1/misc/compress-pdf", + toApiParams: (p: Record) => ({ + optimizeLevel: p.compressionLevel, + }), + }, + }, + } as unknown as Partial; + const automation: AutomationConfig = { + ...sampleAutomation, + operations: [ + { operation: "compress", parameters: { compressionLevel: 9 } }, + ], + }; + const exported = convertToFolderScanningConfig(automation, withMapper); + const parsed = parseFolderScanningConfig(exported, withMapper); + expect(parsed.automation.operations[0]).toEqual({ + operation: "compress", + parameters: { compressionLevel: 9 }, + }); + }); + + test("round-trips a tool whose endpoint depends on a frontend-only field", () => { + // Split-style tool: the endpoint is chosen from a frontend-only `method` + // field. Keeping frontend shape on export lets import replay the endpoint + // and resolve the tool. + const splitLike = { + ...registry, + splitLike: { + operationConfig: { + endpoint: (p: Record) => + p.method === "size" + ? "/api/v1/general/split-by-size" + : "/api/v1/general/split-pages", + }, + }, + } as unknown as Partial; + const automation: AutomationConfig = { + ...sampleAutomation, + operations: [ + { + operation: "splitLike", + parameters: { method: "size", value: "10MB" }, + }, + ], + }; + const exported = convertToFolderScanningConfig(automation, splitLike); + expect(exported.pipeline[0]).toEqual({ + operation: "/api/v1/general/split-by-size", + parameters: { method: "size", value: "10MB", fileInput: "automated" }, + }); + const parsed = parseFolderScanningConfig(exported, splitLike); + expect(parsed.unresolvedOperations).toEqual([]); + expect(parsed.automation.operations[0]).toEqual({ + operation: "splitLike", + parameters: { method: "size", value: "10MB" }, + }); + }); + test("keeps unmappable endpoints verbatim and reports them", () => { const config = { name: "Mystery", diff --git a/frontend/editor/src/core/utils/automationConverter.ts b/frontend/editor/src/core/utils/automationConverter.ts index ce29f71bd0..05acbed8a6 100644 --- a/frontend/editor/src/core/utils/automationConverter.ts +++ b/frontend/editor/src/core/utils/automationConverter.ts @@ -83,7 +83,7 @@ export function convertToFolderScanningConfig( endpoint = endpointConfig; } else if (typeof endpointConfig === "function") { try { - endpoint = endpointConfig(op.parameters); + endpoint = endpointConfig(op.parameters) ?? undefined; } catch (error) { console.warn( `Failed to resolve dynamic endpoint for operation "${op.operation}". ` + diff --git a/frontend/editor/src/core/utils/automationExecutor.ts b/frontend/editor/src/core/utils/automationExecutor.ts index a29ffbcb83..f47778deda 100644 --- a/frontend/editor/src/core/utils/automationExecutor.ts +++ b/frontend/editor/src/core/utils/automationExecutor.ts @@ -88,12 +88,17 @@ const executeSingleFileOperation = async ( ): Promise => { const resultFiles: File[] = []; - for (const file of files) { - const endpoint = - typeof config.endpoint === "function" - ? config.endpoint(parameters) - : config.endpoint; + const endpoint = + typeof config.endpoint === "function" + ? config.endpoint(parameters) + : config.endpoint; + if (!endpoint) { + throw new Error( + "This operation has no backend endpoint and cannot be executed directly.", + ); + } + for (const file of files) { const formData = config.buildFormData(parameters, file); const processedFiles = await executeApiRequest( @@ -122,6 +127,11 @@ const executeMultiFileOperation = async ( typeof config.endpoint === "function" ? config.endpoint(parameters) : config.endpoint; + if (!endpoint) { + throw new Error( + "This operation has no backend endpoint and cannot be executed directly.", + ); + } const formData = config.buildFormData(parameters, files); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3a9db8016c..3d0ff68cd2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -117,6 +117,7 @@ "eslint": "^10.0.2", "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", + "json-schema-to-typescript": "^15.0.4", "license-checker": "^25.0.1", "msw": "^2.14.6", "msw-storybook-addon": "^2.0.7", @@ -192,6 +193,24 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", @@ -2237,6 +2256,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@kessler/tableify": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@kessler/tableify/-/tableify-1.0.2.tgz", @@ -5663,6 +5689,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -10581,6 +10614,30 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -11102,6 +11159,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index dca6e68c43..e9d57208e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -137,6 +137,7 @@ "eslint": "^10.0.2", "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", + "json-schema-to-typescript": "^15.0.4", "license-checker": "^25.0.1", "msw": "^2.14.6", "msw-storybook-addon": "^2.0.7",