diff --git a/.dockerignore b/.dockerignore index 9ce232f0ad..c473e35d87 100644 --- a/.dockerignore +++ b/.dockerignore @@ -98,6 +98,14 @@ configs/ __pycache__/ **/__pycache__/ +# Python virtualenvs. Large, platform-specific, and their symlinks break the build. +.venv/ +**/.venv/ +venv/ +**/venv/ +*.egg-info/ +**/*.egg-info/ + # Local env .env .env.* diff --git a/.editorconfig b/.editorconfig index 665a74a09a..e6bda814c1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,26 +22,15 @@ indent_size = 4 [*.html] indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false -[{*.js,*.jsx,*.mjs,*.ts,*.tsx}] +[{*.js,*.jsx,*.mjs,*.ts,*.tsx,*.mts}] indent_size = 2 [*.css] -# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards. indent_size = 2 [*.{yml,yaml}] -# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices. -indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false - -[*.json] -# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting. indent_size = 2 -[*.jsonc] -# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces. +[*.{json,jsonc}] indent_size = 2 diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index b5cc0527b0..7936145677 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -4,10 +4,12 @@ # instead of matching only the project filter. ci: &ci - .github/workflows/build.yml + - .github/workflows/gradle-cache-prime.yml - .github/config/.files.yaml build: &build - *ci + - buildSrc/** - build.gradle - gradle/spotless.gradle - app/(common|core|proprietary|saas)/build.gradle @@ -15,6 +17,22 @@ build: &build - .taskfiles/backend.yml - .github/workflows/check-licence.yml +# Backend build inputs. This is intentionally broader than `build`: Java and +# backend resource changes must exercise the backend matrix even when Gradle +# build scripts themselves are unchanged. +backend: &backend + - *ci + - *build + - gradle/** + - gradle.properties + - gradlew + - gradlew.bat + - settings.gradle + - app/(common|core|proprietary|saas)/src/(main|test)/java/** + - "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*" + - scripts/db-migration/** + - .github/workflows/backend-build.yml + openapi: &openapi - *ci - *build diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 92de1d9503..883c4f7f46 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -65,6 +65,7 @@ updates: directories: - /devTools - /frontend + - /testing/compose/mcp-client-check schedule: interval: "weekly" cooldown: @@ -93,6 +94,13 @@ updates: - "react-dom" - "@types/react" - "@types/react-dom" + tanstack: + patterns: + - "@tanstack/*" + typescript: + patterns: + - "typescript" + - "@typescript/*" vite: patterns: - "vite" @@ -171,14 +179,6 @@ updates: - "tokio" - "tokio-*" - - package-ecosystem: pip - directory: /testing/cucumber - schedule: - interval: "weekly" - cooldown: - default-days: 7 - rebase-strategy: "auto" - - package-ecosystem: "uv" directory: "/engine" schedule: diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 4375b1b8b0..6420712640 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -39,7 +39,7 @@ jobs: pr_ref: ${{ steps.resolve.outputs.ref }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -121,7 +121,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -475,7 +475,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -560,5 +560,5 @@ jobs: - name: Cleanup temporary files if: always() run: | - rm -f ../private.key + rm -f ../private.key docker-compose.yml storybook.tgz continue-on-error: true diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 410aa82dc9..111dba441f 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -70,7 +70,7 @@ jobs: enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -177,7 +177,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -482,7 +482,7 @@ jobs: issues: write # add/remove labels, delete the command comment steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 098f8d7803..f2912dcedb 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/_runner-pick.yml b/.github/workflows/_runner-pick.yml index d65831c5df..ca2337960a 100644 --- a/.github/workflows/_runner-pick.yml +++ b/.github/workflows/_runner-pick.yml @@ -38,7 +38,7 @@ jobs: is_fork: ${{ steps.decide.outputs.is_fork }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 015934030f..caa5af1acb 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -20,7 +20,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index af0dc85aa6..8c658ec69e 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -26,7 +26,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index 8e6d974f3c..a5ce864d2b 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -18,7 +18,7 @@ jobs: issues: write # labels are applied through the issues API steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 596be96e8c..09c6bbc9a8 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -31,7 +31,7 @@ jobs: flavor: [core, proprietary, saas] steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -197,7 +197,7 @@ jobs: - name: Install uv if: always() && matrix.flavor == 'saas' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 194d86d9da..a5a346db88 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -58,7 +58,7 @@ jobs: SYSTEM_ENABLEANALYTICS: "false" steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -324,6 +324,12 @@ jobs: path: frontend/playwright-report/ retention-days: 7 + - name: Cleanup temporary files + if: always() + run: | + rm -f /tmp/helpers.sh /tmp/backend.log /tmp/backend.pid + continue-on-error: true + # Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml) # and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel). multinode-e2e: @@ -345,13 +351,13 @@ jobs: MN_COMPOSE: docker-compose-multinode.yml steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1feaff2560..16279c67f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,7 @@ jobs: timeout-minutes: 3 outputs: build: ${{ steps.changes.outputs.build }} + backend: ${{ steps.changes.outputs.backend }} project: ${{ steps.changes.outputs.project }} openapi: ${{ steps.changes.outputs.openapi }} frontend: ${{ steps.changes.outputs.frontend }} @@ -48,7 +49,7 @@ jobs: proprietary: ${{ steps.changes.outputs.proprietary }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -61,63 +62,12 @@ jobs: filters: .github/config/.files.yaml gradle-cache-prime: - environment: - name: ci-unsigned - deployment: false - name: Prime shared Gradle cache needs: [files-changed] - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 - with: - egress-policy: audit - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Calculate Gradle cache key - id: gradle-cache-key - shell: bash - run: | - echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT" - - - name: Cache Gradle (lookup-only) - id: cache-gradle-restore - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ steps.gradle-cache-key.outputs.key }} - lookup-only: true - - - name: Set up JDK 25 - if: steps.cache-gradle-restore.outputs.cache-hit != 'true' - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Resolve backend dependencies - if: steps.cache-gradle-restore.outputs.cache-hit != 'true' - run: ./gradlew :stirling-pdf:classes --no-daemon - env: - STIRLING_FLAVOR: saas - MAVEN_USER: ${{ secrets.MAVEN_USER }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - - - name: Save cache Gradle User Home - if: steps.cache-gradle-restore.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ steps.gradle-cache-key.outputs.key }} + uses: ./.github/workflows/gradle-cache-prime.yml + secrets: inherit build: + if: needs.files-changed.outputs.backend == 'true' needs: [files-changed, gradle-cache-prime] permissions: actions: read @@ -196,7 +146,7 @@ jobs: check-licence: if: needs.files-changed.outputs.build == 'true' - needs: [files-changed, build, gradle-cache-prime] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/check-licence.yml @@ -215,7 +165,14 @@ jobs: docker-base-changed: ${{ needs.files-changed.outputs.docker-base }} test-build-docker-images: - if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' + if: | + always() && + github.event_name == 'pull_request' && + needs.files-changed.outputs.project == 'true' && + contains(fromJSON('["success", "skipped"]'), needs.gradle-cache-prime.result) && + contains(fromJSON('["success", "skipped"]'), needs.build.result) && + contains(fromJSON('["success", "skipped"]'), needs.check-generateOpenApiDocs.result) && + contains(fromJSON('["success", "skipped"]'), needs.check-licence.result) needs: [ files-changed, @@ -321,6 +278,7 @@ jobs: if: always() needs: - files-changed + - gradle-cache-prime - build - db-migration-test - check-generateOpenApiDocs @@ -340,7 +298,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -348,6 +306,7 @@ jobs: env: RESULTS: | files-changed=${{ needs.files-changed.result }} + gradle-cache-prime=${{ needs.gradle-cache-prime.result }} build=${{ needs.build.result }} db-migration-test=${{ needs.db-migration-test.result }} check-generateOpenApiDocs=${{ needs.check-generateOpenApiDocs.result }} diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index fafffcc241..476f9d21a7 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -28,7 +28,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 4e04a83656..7626122884 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index bc9b302857..47341834a3 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index 1681546e71..84347defe2 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -28,7 +28,7 @@ jobs: pull-requests: write # Allow writing to pull requests steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -195,7 +195,7 @@ jobs: core.exportVariable("REFERENCE_FILE", referenceFilePath); - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index 61ef8793c4..899bed7f9d 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -34,7 +34,7 @@ jobs: timeout-minutes: 15 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -55,7 +55,7 @@ jobs: distribution: "temurin" - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 9a0d6ec2bb..313dd12f26 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -20,7 +20,7 @@ jobs: timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -83,3 +83,8 @@ jobs: path: /tmp/stirling-migration-failed-*/app.log retention-days: 7 if-no-files-found: warn + + - name: Cleanup temporary files + if: always() + run: rm -rf /tmp/stirling-migration-failed-* + continue-on-error: true diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b27596ddfb..527dca9703 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 439d4240b2..cf5887a205 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -66,11 +66,11 @@ jobs: - name: Install Docker Compose run: | - sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo curl -SL "https://github.com/docker/compose/releases/download/v5.4.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 844d26a3d1..b04f5022cc 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -18,7 +18,7 @@ jobs: timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -106,7 +106,7 @@ jobs: fi - name: Install uv if: always() - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index d02005936c..5038a7585a 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -14,6 +14,11 @@ jobs: playwright-e2e: name: playwright-e2e (${{ matrix.browser }}) runs-on: ubuntu-latest + # The image already contains the Playwright browsers and all Linux + # dependencies. This keeps the matrix for per-browser reporting while + # avoiding three concurrent `playwright install --with-deps` runs. + container: + image: mcr.microsoft.com/playwright:v1.58.2-noble@sha256:6446946a1d9fd62d9ae501312a2d76a43ee688542b21622056a372959b65d63d strategy: # One browser breaking must not mask a failure in another - report all. fail-fast: false @@ -27,7 +32,7 @@ jobs: project: stubbed-webkit steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -40,15 +45,23 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - - name: Install Playwright (${{ matrix.browser }}) - run: task e2e:install -- ${{ matrix.browser }} - name: Build frontend (production bundle for vite preview) env: VITE_BUILD_FOR_PREVIEW: "1" run: task frontend:build - name: Run stubbed E2E tests (${{ matrix.browser }}) env: + # The official Playwright image expects its browser runtime under + # the root home directory. Keep this scoped to Playwright and use a + # neutral Docker config path so Docker does not read /root/.docker. + HOME: /root + DOCKER_CONFIG: /tmp/playwright-docker-config PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json + NPM_CONFIG_PREFER_OFFLINE: "true" + NPM_CONFIG_FETCH_RETRIES: "5" + NPM_CONFIG_FETCH_RETRY_FACTOR: "2" + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "1000" + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000" run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3 - name: Flag flaky tests # Runs regardless of the test outcome: a flaky test (passed on retry) diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index 8f97120d96..247d8375fc 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 25 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 9aef2316d2..96e0edd8ac 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -28,7 +28,7 @@ jobs: licenses-backend: ${{ steps.changes.outputs.licenses-backend }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -56,7 +56,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -334,7 +334,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 133d940b28..2650a945d6 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -107,7 +107,7 @@ jobs: } - name: Install uv if: always() - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/gradle-cache-prime.yml b/.github/workflows/gradle-cache-prime.yml new file mode 100644 index 0000000000..1c79ee4d12 --- /dev/null +++ b/.github/workflows/gradle-cache-prime.yml @@ -0,0 +1,66 @@ +name: Prime Gradle Cache + +on: + workflow_call: + push: + branches: ["main"] + +permissions: + contents: read + +jobs: + gradle-cache-prime: + environment: + name: ci-unsigned + deployment: false + name: Prime shared Gradle cache + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Calculate Gradle cache key + id: gradle-cache-key + shell: bash + run: | + echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT" + + - name: Cache Gradle (lookup-only) + id: cache-gradle-restore + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache-key.outputs.key }} + lookup-only: true + + - name: Set up JDK 25 + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" + + - name: Resolve backend dependencies + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + run: ./gradlew :stirling-pdf:classes --no-daemon + env: + STIRLING_FLAVOR: saas + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + + - name: Save cache Gradle User Home + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache-key.outputs.key }} diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 1a8bdd112d..cc27e45946 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -15,7 +15,7 @@ jobs: issues: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 0ac94ffe68..9071997ad7 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -48,7 +48,7 @@ jobs: version: ${{ steps.versionNumber.outputs.versionNumber }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -140,7 +140,7 @@ jobs: file_suffix: "-server" steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -207,7 +207,7 @@ jobs: RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit allowed-endpoints: > @@ -697,6 +697,17 @@ jobs: path: ./dist/* retention-days: 1 + - name: Cleanup temporary files + if: always() + shell: bash + run: | + rm -f certificate.p12 + rm -rf "$RUNNER_TEMP/msi-verify" + if [ "${{ matrix.platform }}" = "macos-15" ]; then + security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true + fi + continue-on-error: true + collect-and-release: needs: [determine-matrix, build, build-jars] runs-on: ubuntu-latest @@ -704,7 +715,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -861,7 +872,7 @@ jobs: # Gate publish on valid updater sigs. Runs after the review upload (so # artifacts survive for debugging) and before action-gh-release. - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 65c25b7b66..5daa60f56f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -74,7 +74,7 @@ jobs: timeout-minutes: 60 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -140,7 +140,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -148,13 +148,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK 25 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index 777c8d6ed4..02d946e5a6 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -28,7 +28,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -80,7 +80,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml index c642b6cf3d..564ea9cd93 100644 --- a/.github/workflows/pr-conflict-labeler.yml +++ b/.github/workflows/pr-conflict-labeler.yml @@ -32,7 +32,7 @@ jobs: pull-requests: write # pulls.get/list plus add/remove the label on PRs steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index db67ccefe6..674822363b 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 97c227f23c..9da49ad7ae 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -48,7 +48,7 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index ea379cf7c6..844a77b489 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -18,6 +18,16 @@ on: required: false type: boolean default: false + build_engine: + description: "Build & push the standalone stirling-engine image." + required: false + type: boolean + default: true + force_engine_rebuild: + description: "Rebuild stirling-engine even if its source hash is unchanged." + required: false + type: boolean + default: false push: branches: - release @@ -50,9 +60,10 @@ jobs: env: RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }} RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }} + RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -387,3 +398,119 @@ jobs: else echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing" fi + + # Standalone AI engine image, same shape as the unoserver image above. + - name: Compute engine image source hash + id: engineHash + if: env.RUN_ENGINE == 'true' + run: | + set -eu + hash=$( { cat engine/Dockerfile engine/pyproject.toml engine/uv.lock engine/.env; \ + find engine/src -type f -print0 | sort -z | xargs -0 cat; } \ + | sha256sum | cut -d' ' -f1) + echo "hash=${hash}" >> "$GITHUB_OUTPUT" + echo "Engine source hash: ${hash}" + + - name: Decide whether to publish engine image + id: engineDecision + if: env.RUN_ENGINE == 'true' + env: + ENGINE_VERSION: ${{ steps.versionNumber.outputs.versionNumber }} + ENGINE_HASH: ${{ steps.engineHash.outputs.hash }} + ENGINE_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-engine + ENGINE_HASH_ANNOTATION: org.stirlingpdf.engine-source-hash + FORCE_REBUILD: ${{ inputs.force_engine_rebuild }} + GH_REF: ${{ github.ref }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -eu + mode="skip" + tags="" + + read_published_hash() { + local ref="$1" + docker buildx imagetools inspect "$ref" --raw 2>/dev/null \ + | jq -r --arg key "$ENGINE_HASH_ANNOTATION" \ + '.annotations[$key] // empty' \ + 2>/dev/null || true + } + + # Manual dispatch from any branch routes to the :alpha publish path. + EFFECTIVE_REF="$GH_REF" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + EFFECTIVE_REF="refs/heads/testMain" + fi + + case "$EFFECTIVE_REF" in + refs/heads/release) + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_engine_rebuild=true — building stable regardless" + mode="stable" + tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest" + elif docker manifest inspect "${ENGINE_IMAGE}:${ENGINE_VERSION}" >/dev/null 2>&1; then + echo "stirling-engine:${ENGINE_VERSION} already on GHCR — skipping" + else + echo "stirling-engine:${ENGINE_VERSION} is new — will publish" + mode="stable" + tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest" + fi + ;; + refs/heads/main|refs/heads/testMain) + published_hash=$(read_published_hash "${ENGINE_IMAGE}:alpha") + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_engine_rebuild=true — rebuilding :alpha regardless" + mode="alpha" + tags="${ENGINE_IMAGE}:alpha" + elif [ -n "$published_hash" ] && [ "$published_hash" = "$ENGINE_HASH" ]; then + echo "Published :alpha source hash matches (${published_hash}) — skipping" + else + if [ -z "$published_hash" ]; then + echo ":alpha has no source-hash annotation (first publish) — will publish" + else + echo "Source hash changed (was ${published_hash}, now ${ENGINE_HASH}) — will publish" + fi + mode="alpha" + tags="${ENGINE_IMAGE}:alpha" + fi + ;; + *) + echo "Branch ${GH_REF} does not publish engine image" + ;; + esac + echo "mode=${mode}" >> "$GITHUB_OUTPUT" + echo "tags=${tags}" >> "$GITHUB_OUTPUT" + + - name: Build and push engine image + id: build-push-engine + if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode != 'skip' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./engine/Dockerfile + push: true + cache-from: type=gha,scope=stirling-engine + cache-to: type=gha,mode=max,scope=stirling-engine + tags: ${{ steps.engineDecision.outputs.tags }} + # Manifest annotation read by the decision step above to detect drift. + annotations: | + index:org.stirlingpdf.engine-source-hash=${{ steps.engineHash.outputs.hash }} + platforms: linux/amd64,linux/arm64/v8 + provenance: true + sbom: true + + - name: Sign engine image + if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode == 'stable' + env: + DIGEST: ${{ steps.build-push-engine.outputs.digest }} + TAGS: ${{ steps.engineDecision.outputs.tags }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + if [ -n "$COSIGN_PRIVATE_KEY" ]; then + echo "$TAGS" | tr ',' '\n' | while read -r tag; do + cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}" + done + else + echo "Warning: COSIGN_PRIVATE_KEY not set, skipping engine image signing" + fi diff --git a/.github/workflows/rollback-latest.yml b/.github/workflows/rollback-latest.yml index 3e49f727c6..fb0289b5a0 100644 --- a/.github/workflows/rollback-latest.yml +++ b/.github/workflows/rollback-latest.yml @@ -19,7 +19,7 @@ jobs: packages: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index f0129277de..dbda06764b 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -75,6 +75,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index af255de3cf..2033154a00 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 1bfc94be5b..1f53edd17b 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/sync-portal-docs.yml b/.github/workflows/sync-portal-docs.yml index 636cd5f2e1..a6073b3f1f 100644 --- a/.github/workflows/sync-portal-docs.yml +++ b/.github/workflows/sync-portal-docs.yml @@ -33,7 +33,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 124c992c71..a199fd6cbd 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -53,7 +53,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 0a82647690..3b7a0b04fa 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -72,7 +72,7 @@ jobs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -134,7 +134,7 @@ jobs: SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -677,6 +677,17 @@ jobs: fi done + - name: Cleanup temporary files + if: always() + shell: bash + run: | + rm -f certificate.p12 + rm -rf "$RUNNER_TEMP/msi-verify" + if [ "${{ matrix.platform }}" = "macos-15" ]; then + security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true + fi + continue-on-error: true + pr-comment: needs: build runs-on: ubuntu-latest @@ -692,7 +703,7 @@ jobs: pull-requests: write steps: - name: Harden the runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -784,7 +795,7 @@ jobs: if: always() steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 37cb7cb546..4717c9c387 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -22,21 +22,42 @@ permissions: contents: read jobs: - # TODO: extract a pre-matrix `prepare` job that runs once and produces - # shared artifacts for the three matrix entries below to consume: - # 1. `task backend:build` — currently runs 3× in parallel with - # identical env (DISABLE_ADDITIONAL_FEATURES=true, - # STIRLING_PDF_DESKTOP_UI=false). Build once, upload the JAR as an - # artifact, matrix entries download. - # 2. The base-image `docker build` (gated on docker-base-changed) — - # currently runs 3× in parallel against the same Dockerfile and - # context. Build once, `docker save` to an artifact, matrix entries - # `docker load` before the embedded build. - # Saves ~2 full backend builds + 2 base-image builds per PR that touches - # docker. May also be reusable from backend-build.yml's jdk-25 + - # spring-security=true matrix entry if `task backend:build` and - # `task backend:build:ci` produce equivalent JARs (verify before wiring). + # A changed base image is shared by all three embedded-image builds. Build + # it once and transfer it as an artifact; the matrix jobs use the local + # Docker driver so the loaded image is visible to the build. + prepare-base-image: + if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' + environment: + name: ci-unsigned + deployment: false + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build base image locally + run: docker build --platform linux/amd64 -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base + + - name: Export base image + run: docker save stirling-pdf-base:pr-test | gzip -1 > stirling-pdf-base-pr-test.tar.gz + + - name: Upload base image + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docker-base-pr-test + path: stirling-pdf-base-pr-test.tar.gz + retention-days: 1 + if-no-files-found: error + test-build-docker-images: + if: always() && (needs.prepare-base-image.result == 'success' || needs.prepare-base-image.result == 'skipped') + needs: [prepare-base-image] environment: name: ci-unsigned deployment: false @@ -56,7 +77,7 @@ jobs: cache-scope: stirling-pdf-fat steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -81,6 +102,16 @@ jobs: docker system prune -af || true echo "Disk space after cleanup:" && df -h + - name: Download prepared base image + if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: docker-base-pr-test + + - name: Load prepared base image + if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' + run: gzip -dc stirling-pdf-base-pr-test.tar.gz | docker load + - name: Restore cache Gradle User Home uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -113,11 +144,6 @@ jobs: id: buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Build base image locally (PR base change only) - if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' - run: | - docker build -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base - - name: Set base image and platform for this build id: build-params # Pass workflow inputs through env vars rather than expanding `${{ }}` @@ -191,7 +217,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/update-gradle.yml b/.github/workflows/update-gradle.yml new file mode 100644 index 0000000000..07474f112b --- /dev/null +++ b/.github/workflows/update-gradle.yml @@ -0,0 +1,112 @@ +name: Update Gradle + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * 1" + +concurrency: + group: update-gradle + cancel-in-progress: true + +jobs: + update-gradle: + name: Update Gradle and Docker images + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: "25" + + - name: Find latest Gradle release + id: gradle + shell: bash + run: | + set -euo pipefail + version=$(curl --fail --silent --show-error --retry 3 \ + https://services.gradle.org/versions/current | jq -r '.version') + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Could not determine a stable Gradle version: $version" >&2 + exit 1 + } + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Find matching Docker image digest + id: docker + env: + GRADLE_VERSION: ${{ steps.gradle.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag="${GRADLE_VERSION}-jdk25" + digest=$(curl --fail --silent --show-error --retry 3 \ + "https://hub.docker.com/v2/repositories/library/gradle/tags/${tag}" \ + | jq -r '.digest // empty') + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { + echo "Docker image gradle:${tag} was not found" >&2 + exit 1 + } + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + - name: Update Gradle wrapper + env: + GRADLE_VERSION: ${{ steps.gradle.outputs.version }} + run: ./gradlew wrapper --gradle-version "$GRADLE_VERSION" --distribution-type bin + + - name: Update Gradle Docker images + env: + DOCKER_TAG: ${{ steps.docker.outputs.tag }} + DOCKER_DIGEST: ${{ steps.docker.outputs.digest }} + shell: bash + run: | + set -euo pipefail + find docker -type f -name 'Dockerfile*' -print0 | + xargs -0 sed -E -i \ + "s#gradle:[^@[:space:]]+-jdk25(@sha256:[^[:space:]]+)?#gradle:${DOCKER_TAG}@${DOCKER_DIGEST}#g" + + - name: Verify Gradle update + env: + EXPECTED_VERSION: ${{ steps.gradle.outputs.version }} + shell: bash + run: | + set -euo pipefail + actual=$(./gradlew --version | sed -n 's/^Gradle \([0-9.]*\)$/\1/p') + [[ "$actual" == "$EXPECTED_VERSION" ]] || { + echo "Wrapper resolved Gradle $actual, expected $EXPECTED_VERSION" >&2 + exit 1 + } + if git diff --quiet; then + echo "Gradle is already up to date." + exit 0 + fi + git diff --check + + - name: Create pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: automation/update-gradle + delete-branch: true + commit-message: "chore: update Gradle" + title: "chore: update Gradle to ${{ steps.gradle.outputs.version }}" + body: | + Automated update of the Gradle wrapper and Gradle Docker build images. + + Gradle version: `${{ steps.gradle.outputs.version }}` + Docker image: `gradle:${{ steps.docker.outputs.tag }}` + labels: dependencies diff --git a/.gitignore b/.gitignore index 3d4e851cf6..5f406585ff 100644 --- a/.gitignore +++ b/.gitignore @@ -181,6 +181,8 @@ app/core/src/main/resources/static/images/google-drive.svg *.nar *.ear *.zip +# Real backend archives the form-bundle reader is tested against. +!frontend/editor/src/core/tools/formFill/__fixtures__/*.zip *.tar.gz *.rar *.db diff --git a/.imgbotconfig b/.imgbotconfig index 720b938475..6a1b1bf7a2 100644 --- a/.imgbotconfig +++ b/.imgbotconfig @@ -1,5 +1,7 @@ { "ignoredFiles": [ - "frontend/editor/src-tauri/icons/icon.png" + "frontend/editor/src-tauri/icons/macos/*", + "frontend/editor/src-tauri/icons/linux/*", + "frontend/editor/src-tauri/icons/windows/*" ] } diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index f5325c9ed7..844306824d 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -375,13 +375,13 @@ tasks: desc: "Auto-fix code formatting" deps: [install] cmds: - - npx prettier --write . + - npx oxfmt --write . format:check: desc: "Check code formatting" deps: [install] cmds: - - npx prettier --check . + - npx oxfmt --check . fix: desc: "Auto-fix lint and format" @@ -554,6 +554,7 @@ tasks: deps: [install, ":backend:swagger"] cmds: - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts + - task: format sources: - editor/scripts/generate-tool-api-types.mts - ../SwaggerDoc.json @@ -563,9 +564,9 @@ tasks: 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 --io-output editor/src/core/types/toolIO.ts --check + - task: tool-models + - git diff --exit-code -- editor/src/core/types/toolApiTypes.ts editor/src/core/types/toolIO.ts licenses:generate: desc: "Generate frontend license report" diff --git a/DeveloperGuide.md b/DeveloperGuide.md index 7c2e21d5ca..d87406715b 100644 --- a/DeveloperGuide.md +++ b/DeveloperGuide.md @@ -46,8 +46,8 @@ This guide focuses on developing for Stirling 2.0, including both the React fron - Docker - Git - Java JDK 25 -- Node.js 18+ and npm (required for frontend development) -- Gradle 7.0 or later (Included within the repo) +- Node.js 22+ and npm (required for frontend development) +- Gradle 9.0 or later (Included within the repo) - [uv](https://docs.astral.sh/uv/) — Python package manager (required for engine development) - Rust and Cargo (required for Tauri desktop app development) - Tauri CLI (install with `cargo install tauri-cli`) diff --git a/app/common/build.gradle b/app/common/build.gradle index aa780dc6ce..52c5b443ca 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -20,7 +20,7 @@ dependencies { api 'at.favre.lib:bcrypt:0.10.2' // Swagger/OpenAPI annotations (io.swagger.v3.oas.annotations.*) used by common's API marker // interfaces; was transitive via springdoc. Quarkus' SmallRye OpenAPI also understands these. - api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46' + api 'io.swagger.core.v3:swagger-core-jakarta:2.2.53' // REMOVED: spring-boot-starter-aspectj. Quarkus has no AspectJ weaving; quarkus-arc provides // CDI interceptors (@AroundInvoke / interceptor bindings) instead. api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1' @@ -35,12 +35,12 @@ dependencies { api "org.apache.pdfbox:preflight:$pdfboxVersion" api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' - api 'org.snakeyaml:snakeyaml-engine:3.0.1' + api 'org.snakeyaml:snakeyaml-engine:3.1.1' // springdoc-openapi-starter-webmvc-ui -> SmallRye OpenAPI (schema at /q/openapi, UI at /q/swagger-ui) api 'io.quarkus:quarkus-smallrye-openapi' // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) - api 'org.simplejavamail:simple-java-mail:9.2.0' - api 'org.simplejavamail:outlook-module:9.2.0' // MSG file support + api 'org.simplejavamail:simple-java-mail:9.3.2' + api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support api 'jakarta.mail:jakarta.mail-api:2.1.5' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5' diff --git a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java index 54ccafd665..9a0f23aa33 100644 --- a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java +++ b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java @@ -62,6 +62,15 @@ public class FormFieldWithCoordinates { @Schema(description = "Widget coordinates on each page (fields can have multiple widgets)") private List widgets; + @Schema(description = "Maximum character count for a text field (/MaxLen); null when unset") + private Integer maxLength; + + @Schema( + description = + "Push button activation action as a spec string:" + + " 'reset', 'print', 'uri:' or 'submit:'") + private String buttonActionSpec; + /** * Coordinates for a single widget annotation (visual representation of the field). A field can * have multiple widgets if it appears on multiple pages. @@ -94,5 +103,12 @@ public class FormFieldWithCoordinates { @Schema(description = "Font size in PDF points") private Float fontSize; + + @Schema( + description = + "CropBox height in PDF points. Lets the frontend reverse the backend's" + + " Y-flip when sending new widget coordinates back for" + + " create/modify operations.") + private Float cropBoxHeight; } } diff --git a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java index 1f971d8d9e..5d833290ec 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java +++ b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java @@ -3,14 +3,20 @@ package stirling.software.common.util; import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; @@ -59,6 +65,24 @@ public enum FormFieldTypeSupport { List options) throws IOException { PDTextField textField = (PDTextField) field; + if (definition.fontSize() != null && definition.fontSize() > 0) { + textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g"); + } + if (Boolean.TRUE.equals(definition.multiline())) { + textField.setMultiline(true); + } + // Comb field: evenly spaced character cells (e.g. SSN, phone). Requires + // a positive MaxLen and is mutually exclusive with multiline. + if (definition.maxLength() != null && definition.maxLength() > 0) { + textField.setMaxLen(definition.maxLength()); + if (!Boolean.TRUE.equals(definition.multiline())) { + try { + textField.setComb(true); + } catch (Exception e) { + log.debug("Unable to set comb flag: {}", e.getMessage()); + } + } + } String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse(""); if (!defaultValue.isBlank()) { FormUtils.setTextValue(textField, defaultValue); @@ -272,14 +296,108 @@ public enum FormFieldTypeSupport { PDTerminalField createField(PDAcroForm acroForm) { return new PDSignatureField(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + // Empty signature placeholder: no value to apply (signed later by a sign tool). }, BUTTON("button", "pushButton", PDPushButton.class) { @Override PDTerminalField createField(PDAcroForm acroForm) { return new PDPushButton(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + if (field.getWidgets().isEmpty()) { + return; + } + PDAnnotationWidget widget = field.getWidgets().get(0); + + // Visible caption (/MK /CA). + String caption = definition.label(); + if (caption == null || caption.isBlank()) { + caption = definition.name(); + } + if (caption != null && !caption.isBlank()) { + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + if (mk == null) { + mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject()); + widget.setAppearanceCharacteristics(mk); + } + mk.setNormalCaption(caption); + } + widget.setPrinted(true); + + applyButtonAction(widget, definition.buttonAction()); + } }; + /** + * Writes a push button's activation action from a "reset"/"print"/"uri:"/"submit:" spec, + * returning why it could not, or null on success. A blank spec clears the action. + */ + public static String applyButtonAction(PDAnnotationWidget widget, String action) { + if (action == null) { + return null; + } + if (action.isBlank()) { + // An explicit blank clears the action rather than leaving the old one behind. + widget.getCOSObject().removeItem(COSName.A); + return null; + } + String spec = action.trim(); + if (!ACTION_SPEC.matcher(spec).matches()) { + return "'" + action + "' is not a button action this editor understands"; + } + // The editor emits "uri:" the moment that kind is picked, before a URL is typed; an + // empty target is not yet an action, so clear rather than write an inert one. + int colon = spec.indexOf(':'); + if (colon >= 0 && spec.substring(colon + 1).isBlank()) { + widget.getCOSObject().removeItem(COSName.A); + return null; + } + try { + String lower = spec.toLowerCase(Locale.ROOT); + if (lower.equals("reset")) { + widget.getCOSObject().setItem(COSName.A, new PDActionResetForm().getCOSObject()); + } else if (lower.equals("print")) { + PDActionNamed named = new PDActionNamed(); + named.setN("Print"); + widget.getCOSObject().setItem(COSName.A, named.getCOSObject()); + } else if (lower.startsWith("uri:")) { + PDActionURI uri = new PDActionURI(); + uri.setURI(spec.substring(4)); + widget.getCOSObject().setItem(COSName.A, uri.getCOSObject()); + } else if (lower.startsWith("submit:")) { + PDActionSubmitForm submit = new PDActionSubmitForm(); + // Store the target URL on the action dictionary's /F entry. + submit.getCOSObject().setString(COSName.F, spec.substring(7)); + widget.getCOSObject().setItem(COSName.A, submit.getCOSObject()); + } + return null; + } catch (Exception e) { + log.debug("Unable to apply button action '{}': {}", action, e.getMessage()); + return e.getMessage(); + } + } + + /** The spec forms applyButtonAction understands; anything else is reported, not dropped. */ + private static final Pattern ACTION_SPEC = + Pattern.compile( + "^(reset|print|uri:.*|submit:.*)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Map BY_TYPE = Arrays.stream(values()) .collect( diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java index a1862d379c..401b1eb1ac 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -23,6 +23,7 @@ import org.apache.pdfbox.cos.COSArray; import org.apache.pdfbox.cos.COSBase; import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDPage; @@ -32,10 +33,12 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.font.PDFont; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; @@ -68,6 +71,12 @@ public class FormUtils { public final Set CHOICE_FIELD_TYPES = Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO); + /** The reserved off-state name every toggle widget must carry an appearance for. */ + private final String OFF_STATE = "Off"; + + /** The on-state a checkbox gets when the definition supplies no export values. */ + private final String DEFAULT_CHECKBOX_ON_STATE = "Yes"; + /** * Threshold in PDF points for considering two widgets to be on the same line. Fields whose * y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead @@ -75,6 +84,9 @@ public class FormUtils { */ private static final float SAME_LINE_THRESHOLD_PT = 10.0f; + /** Below this, a rect change is a no-op and the existing /AP still maps exactly. */ + private static final float GEOMETRY_EPSILON_PT = 0.01f; + private static final Pattern HEX_UUID_PATTERN = Pattern.compile("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"); private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); @@ -236,6 +248,8 @@ public class FormUtils { .multiline(multiline) .tooltip(tooltip) .widgets(widgets.isEmpty() ? null : widgets) + .maxLength(extractMaxLength(terminalField)) + .buttonActionSpec(extractButtonAction(terminalField)) .build()); } @@ -300,7 +314,8 @@ public class FormUtils { findPageIndexForAnnotation(document, fieldDict, annotationPageMap); if (pageIndex >= 0) { PDRectangle rectangle = new PDRectangle(rectArray); - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, null, field)); } else { @@ -373,7 +388,8 @@ public class FormUtils { } } - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, exportValue, field)); } catch (Exception e) { @@ -387,6 +403,15 @@ public class FormUtils { return result; } + /** Unreadable geometry yields null, which must never reach the list the comparator walks. */ + private void addWidget( + List target, + FormFieldWithCoordinates.WidgetCoordinates widget) { + if (widget != null) { + target.add(widget); + } + } + private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates( PDDocument document, PDRectangle rectangle, @@ -424,13 +449,14 @@ public class FormUtils { float finalW = width; float finalH = height; - // Validate coordinates are within reasonable bounds - if (finalX < -1.0f - || finalY < -1.0f - || finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow - || finalY > cropHeight + 1.0f) { + // Only nonsense is rejected. A widget outside the visible page is legal and must still be + // reported, or the field loses its geometry and the user cannot drag it back. + if (!Float.isFinite(finalX) + || !Float.isFinite(finalY) + || !Float.isFinite(finalW) + || !Float.isFinite(finalH)) { log.warn( - "Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}", + "Widget coordinates are not finite for field '{}': page={}, x={}, y={}, w={}, h={}", field.getFullyQualifiedName(), pageIndex, finalX, @@ -439,6 +465,12 @@ public class FormUtils { finalH); return null; } + if (finalX < 0 || finalY < 0 || finalX > cropBox.getWidth() || finalY > cropHeight) { + log.debug( + "Widget for field '{}' sits outside page {}", + field.getFullyQualifiedName(), + pageIndex); + } return FormFieldWithCoordinates.WidgetCoordinates.builder() .pageIndex(pageIndex) @@ -448,6 +480,7 @@ public class FormUtils { .height(finalH) .exportValue(exportValue) .fontSize(extractFontSize(field)) + .cropBoxHeight(cropHeight) .build(); } @@ -459,12 +492,40 @@ public class FormUtils { * * @param document PDF document to repair */ + /** + * PDFBox reads /Opt entries without following references, so an option stored indirectly - as + * real forms do - silently disappears. Resolving in place keeps the value and the reader + * honest. + */ + private void resolveIndirectChoiceOptions(PDAcroForm acroForm) { + try { + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDChoice)) { + continue; + } + COSBase raw = field.getCOSObject().getDictionaryObject(COSName.OPT); + if (!(raw instanceof COSArray options)) { + continue; + } + for (int i = 0; i < options.size(); i++) { + COSBase resolved = options.getObject(i); + if (resolved != null && resolved != options.get(i)) { + options.set(i, resolved); + } + } + } + } catch (Exception e) { + log.debug("Could not resolve indirect choice options: {}", e.getMessage()); + } + } + public void repairMissingWidgetPageReferences(PDDocument document) { try { PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { return; } + resolveIndirectChoiceOptions(acroForm); log.debug("Checking for widgets with missing page references..."); int repairedCount = 0; @@ -887,7 +948,9 @@ public class FormUtils { } PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); try { - // Map standard name used by many DAs + // Both spellings: a DA naming a font missing from /DR makes + // refreshAppearances throw for the whole form, not just that field. + dr.put(COSName.getPDFName("Helv"), helvetica); dr.put(COSName.getPDFName("Helvetica"), helvetica); } catch (Exception ignore) { try { @@ -991,7 +1054,7 @@ public class FormUtils { try { textField.setValue(value != null ? value : ""); return; - } catch (IOException initial) { + } catch (IOException | RuntimeException initial) { log.debug( "Primary fill failed for text field '{}': {}", textField.getFullyQualifiedName(), @@ -1277,6 +1340,11 @@ public class FormUtils { } return String.join(",", selected); } + // A signature has no text value; getValueAsString would emit a JVM identity hash that + // changes on every load, so the same document would describe itself differently. + if (field instanceof PDSignatureField) { + return null; + } return field.getValueAsString(); } catch (Exception e) { log.debug( @@ -1451,16 +1519,26 @@ public class FormUtils { return tooltipLabel; } - // Only check options for choice-type fields (combobox, listbox, radio) - if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { + // A clearly meaningful field name describes the whole field and matches + // the name shown in the editor, so it is the best label. + String humanized = cleanLabel(humanizeName(name)); + if (humanized != null && !looksGeneric(humanized)) { + return humanized; + } + + // An option only beats the name when the name is auto-generated; a human-typed + // one wins below, so a group named "Choice" does not read as its first option. + if (CHOICE_FIELD_TYPES.contains(type) + && options != null + && !options.isEmpty() + && looksAutoGenerated(name)) { String optionCandidate = cleanLabel(options.getFirst()); if (optionCandidate != null && !looksGeneric(optionCandidate)) { return optionCandidate; } } - String humanized = cleanLabel(humanizeName(name)); - if (humanized != null && !looksGeneric(humanized)) { + if (humanized != null && !looksAutoGenerated(name)) { return humanized; } @@ -1497,6 +1575,27 @@ public class FormUtils { || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); } + /** + * True only for auto-generated identifiers ("Field_5", "t3", UUIDs). Unlike {@link + * #looksGeneric} it keeps human-typed placeholders like "Choice", which are usable labels. + */ + private boolean looksAutoGenerated(String value) { + if (value == null) return true; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String simplified = patterns.getPunctuationPattern().matcher(value).replaceAll(" ").trim(); + if (simplified.isEmpty()) return true; + + String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll(""); + if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true; + + return patterns.getPattern("^field(\\s*\\d+)?$", Pattern.CASE_INSENSITIVE) + .matcher(simplified) + .matches() + || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() + || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); + } + private String humanizeName(String name) { if (name == null) return null; @@ -1513,35 +1612,62 @@ public class FormUtils { public void modifyFormFields( PDDocument document, List modifications) { + modifyFormFields(document, modifications, null); + } + + public void modifyFormFields( + PDDocument document, + List modifications, + List skipped) { if (document == null || modifications == null || modifications.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot modify fields because the document has no AcroForm"); + for (ModifyFormFieldDefinition modification : modifications) { + if (modification != null) { + recordSkip( + skipped, + "modify", + modification.targetName(), + "the document has no form to modify"); + } + } return; } Set existingNames = collectExistingFieldNames(acroForm); for (ModifyFormFieldDefinition modification : modifications) { - if (modification == null || modification.targetName() == null) { + if (modification == null) { continue; } - String lookupName = modification.targetName().trim(); + String lookupName = + modification.targetName() == null ? "" : modification.targetName().trim(); if (lookupName.isEmpty()) { + recordSkip(skipped, "modify", null, "the request named no field to change"); + continue; + } + + String nameProblem = renameProblem(lookupName, modification.name()); + if (nameProblem != null) { + log.warn("Rejecting rename of '{}': {}", sanitizeForLog(lookupName), nameProblem); + recordSkip(skipped, "modify", lookupName, nameProblem); continue; } PDField originalField = locateField(acroForm, lookupName); if (originalField == null) { log.warn("No matching field '{}' found for modification", lookupName); + recordSkip(skipped, "modify", lookupName, "no field with that name exists"); continue; } List widgets = originalField.getWidgets(); if (widgets == null || widgets.isEmpty()) { log.warn("Field '{}' has no widgets; skipping modification", lookupName); + recordSkip(skipped, "modify", lookupName, "the field has nothing drawn on a page"); continue; } @@ -1552,6 +1678,8 @@ public class FormUtils { log.warn( "Unable to resolve widget page or rectangle for '{}'; skipping", lookupName); + recordSkip( + skipped, "modify", lookupName, "the field is not placed on a known page"); continue; } @@ -1564,6 +1692,11 @@ public class FormUtils { .getSupportedNewFieldTypes() .contains(resolvedType)) { log.warn("Unsupported target type '{}' for field '{}'", resolvedType, lookupName); + recordSkip( + skipped, + "modify", + lookupName, + "'" + abbreviate(resolvedType, 60) + "' is not a supported field type"); continue; } @@ -1571,13 +1704,22 @@ public class FormUtils { Optional.ofNullable(modification.name()) .map(String::trim) .filter(s -> !s.isEmpty()) + // The editor seeds the box with the qualified name, so a submission + // equal to it is not a rename; keep the field's own partial name. + .filter(name -> !name.equals(lookupName)) + .map(name -> leafName(lookupName, name)) .orElseGet(originalField::getPartialName); + String qualified = originalField.getFullyQualifiedName(); + // desiredName is a PARTIAL name but existingNames holds qualified ones, so compare + // under this field's own parent or siblings collide unnoticed. + String prefix = parentPrefix(qualified); + String reservedName = null; if (desiredName != null) { - existingNames.remove(originalField.getFullyQualifiedName()); - existingNames.remove(originalField.getPartialName()); - desiredName = generateUniqueFieldName(desiredName, existingNames); - existingNames.add(desiredName); + existingNames.remove(qualified); + desiredName = generateUniqueFieldName(desiredName, existingNames, prefix); + reservedName = prefix + desiredName; + existingNames.add(reservedName); } // Try to modify field in-place first for simple property changes @@ -1586,17 +1728,27 @@ public class FormUtils { if (!typeChanging) { try { - modifyFieldPropertiesInPlace(originalField, modification, desiredName); + modifyFieldPropertiesInPlace( + document, originalField, modification, desiredName, skipped); log.debug("Successfully modified field '{}' in-place", lookupName); continue; // Skip the remove-and-recreate process } catch (Exception e) { log.debug( "In-place modification failed for '{}', falling back to recreation: {}", - lookupName, + sanitizeForLog(lookupName), e.getMessage()); } } + // Recreation always builds a top-level field, so running it on a field nested under a + // parent would silently move it out of that parent and change its qualified name. + if (!prefix.isEmpty()) { + log.warn("Cannot recreate nested field '{}'; leaving it as it was", lookupName); + recordSkip(skipped, "modify", lookupName, refusalReason(typeChanging)); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + // For type changes or when in-place modification fails, use remove-and-recreate // But create the new field first to ensure success before removing the original NewFormFieldDefinition replacementDefinition = @@ -1613,16 +1765,31 @@ public class FormUtils { modification.multiSelect(), modification.options(), modification.defaultValue(), - modification.tooltip()); + modification.tooltip(), + modification.fontSize(), + modification.readOnly(), + modification.multiline(), + modification.maxLength(), + modification.buttonAction()); List sanitizedOptions = sanitizeOptions(modification.options()); - try { - FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); - if (handler == null || handler.doesNotsupportsDefinitionCreation()) { - handler = FormFieldTypeSupport.TEXT; - } + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Falling back to a text field here would silently retype the field and report + // success, so refuse instead and leave the original alone. + recordSkip( + skipped, + "modify", + lookupName, + "'" + + resolvedType + + "' cannot be rebuilt, so the field was left as it was"); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + try { // Create new field first - if this fails, original field is preserved createNewField( handler, @@ -1635,25 +1802,56 @@ public class FormUtils { removeFieldFromDocument(document, acroForm, originalField); + // A rebuilt toggle has no /AP yet, so without this it renders blank and cannot + // tick. + applyButtonAppearances( + document, + acroForm, + List.of(Map.entry(prefix + desiredName, replacementDefinition))); + log.debug( "Successfully replaced field '{}' with type '{}'", - lookupName, + sanitizeForLog(lookupName), resolvedType); } catch (Exception e) { log.warn( "Failed to modify form field '{}' to type '{}': {}", - lookupName, + sanitizeForLog(lookupName), resolvedType, e.getMessage(), e); + recordSkip(skipped, "modify", lookupName, readableFailure(e)); + releaseReservedName(existingNames, reservedName, qualified); } } ensureAppearances(acroForm); } + /** Nothing was applied, so give the field back its real name and drop the one we reserved. */ + private void releaseReservedName( + Set existingNames, String reservedName, String originalQualifiedName) { + if (reservedName != null) { + existingNames.remove(reservedName); + } + if (originalQualifiedName != null) { + existingNames.add(originalQualifiedName); + } + } + + /** Why the edit was refused, which is not always the type change that triggered the path. */ + private String refusalReason(boolean typeChanging) { + return typeChanging + ? "a field nested under a parent cannot have its type changed here" + : "this change needs the field rebuilt, which a nested field does not support"; + } + private void modifyFieldPropertiesInPlace( - PDField field, ModifyFormFieldDefinition modification, String newName) + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + String newName, + List skipped) throws IOException { if (newName != null && !newName.equals(field.getPartialName())) { field.setPartialName(newName); @@ -1690,6 +1888,14 @@ public class FormUtils { if (modification.multiSelect() != null) { choiceField.setMultiSelect(modification.multiSelect()); } + } else if (modification.options() != null && !(field instanceof PDChoice)) { + // Only a choice field stores an option list, so say so rather than drop the edit and + // let the panel report it as saved. + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "only dropdown and list fields have an editable option list"); } // Update tooltip on widgets @@ -1703,6 +1909,318 @@ public class FormUtils { } } } + + // Update read-only flag + if (modification.readOnly() != null) { + field.setReadOnly(modification.readOnly()); + } + + // Update multiline flag (text fields only) + if (modification.multiline() != null && field instanceof PDTextField tf) { + tf.setMultiline(modification.multiline()); + } + + // Update the activation action (push buttons only) + if (modification.buttonAction() != null && field instanceof PDPushButton) { + String actionProblem = null; + for (PDAnnotationWidget widget : field.getWidgets()) { + String problem = + FormFieldTypeSupport.applyButtonAction(widget, modification.buttonAction()); + if (problem != null && actionProblem == null) { + actionProblem = problem; + } + } + if (actionProblem != null) { + recordSkip(skipped, "modify", field.getFullyQualifiedName(), actionProblem); + } + } + + // Update comb / max length (text fields only). Zero clears it, since a null means + // "unchanged" and the editor otherwise has no way to remove an existing /MaxLen. + if (modification.maxLength() != null && field instanceof PDTextField combTf) { + int maxLength = modification.maxLength(); + if (maxLength > 0) { + combTf.setMaxLen(maxLength); + if (!combTf.isMultiline()) { + try { + combTf.setComb(true); + } catch (Exception ignore) { + // comb is best-effort + } + } + } else { + combTf.getCOSObject().removeItem(COSName.MAX_LEN); + try { + combTf.setComb(false); + } catch (Exception ignore) { + // comb is best-effort + } + } + } + + // Update font size (variable-text fields only: text/combo/list) + if (modification.fontSize() != null + && modification.fontSize() > 0 + && field instanceof PDVariableText vt) { + applyFontSizeToDefaultAppearance(vt, modification.fontSize()); + // Clear the cached appearance so ensureAppearances() regenerates it + // with the new font size; otherwise viewers keep the old glyph sizing. + removeWidgetAppearanceStreams(field); + } + + // Incoming coordinates are CropBox-relative and lower-left-origin, the reverse + // of what createWidgetCoordinates extracts. + if (modification.x() != null + || modification.y() != null + || modification.width() != null + || modification.height() != null + || modification.optionGap() != null + || modification.optionSize() != null) { + updateWidgetGeometry(document, field, modification, skipped); + } + } + + /** + * Moves/resizes a field's widgets. The rect describes widget 0; the rest shift by the same + * delta and keep their own size, so a radio group travels intact instead of being normalised. + */ + /** A size PDFBox would write as "Infinity" or a zero-area box makes the field unusable. */ + private static boolean unusableSize(Float value) { + return value != null && (!Float.isFinite(value) || value <= 0); + } + + /** The rectangle enclosing every widget, or null when none has one. */ + private static PDRectangle widgetBounds(List widgets) { + float minX = Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + float maxY = -Float.MAX_VALUE; + boolean any = false; + for (PDAnnotationWidget widget : widgets) { + PDRectangle r = widget.getRectangle(); + if (r == null) continue; + any = true; + minX = Math.min(minX, r.getLowerLeftX()); + minY = Math.min(minY, r.getLowerLeftY()); + maxX = Math.max(maxX, r.getUpperRightX()); + maxY = Math.max(maxY, r.getUpperRightY()); + } + return any ? new PDRectangle(minX, minY, maxX - minX, maxY - minY) : null; + } + + private void updateWidgetGeometry( + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + List skipped) { + List widgets = field.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + return; + } + if (unusableSize(modification.width()) || unusableSize(modification.height())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "a width and height above zero are required, so the size was left as it was"); + return; + } + if (modification.x() != null && !Float.isFinite(modification.x()) + || modification.y() != null && !Float.isFinite(modification.y())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "the position is not a usable number, so the field was left where it was"); + return; + } + PDAnnotationWidget anchor = widgets.get(0); + PDRectangle anchorRect = anchor.getRectangle(); + if (anchorRect == null) { + return; + } + + Map pageMap = buildAnnotationPageMap(document); + int anchorPage = determineWidgetPageIndex(document, anchor, pageMap); + float offX = 0; + float offY = 0; + if (anchorPage >= 0) { + PDRectangle cropBox = document.getPage(anchorPage).getCropBox(); + offX = cropBox.getLowerLeftX(); + offY = cropBox.getLowerLeftY(); + } + + float newX = + modification.x() != null ? modification.x() + offX : anchorRect.getLowerLeftX(); + float newY = + modification.y() != null ? modification.y() + offY : anchorRect.getLowerLeftY(); + float dx = newX - anchorRect.getLowerLeftX(); + float dy = newY - anchorRect.getLowerLeftY(); + Float newW = modification.width(); + Float newH = modification.height(); + + // Spacing and size are a property of the whole group, so an explicit change re-flows + // every option. Gated on those two: a plain drag sends widget 0's size, which would be + // mistaken for the group's height and collapse the stack. + if ((modification.optionGap() != null || modification.optionSize() != null) + && field instanceof PDRadioButton + && widgets.size() > 1) { + PDRectangle bounds = widgetBounds(widgets); + if (bounds != null) { + PDRectangle box = + new PDRectangle( + bounds.getLowerLeftX() + dx, + bounds.getLowerLeftY() + dy, + modification.width() != null + ? modification.width() + : bounds.getWidth(), + modification.height() != null + ? modification.height() + : bounds.getHeight()); + List reflowed = + radioOptionRects( + box, + widgets.size(), + modification.optionGap(), + modification.optionSize()); + List groupStates = currentWidgetOnStates((PDButton) field); + for (int i = 0; i < widgets.size(); i++) { + widgets.get(i).setRectangle(reflowed.get(i)); + rebuildWidgetAppearance(document, field, widgets.get(i), i, groupStates, true); + } + return; + } + } + + // Read the on-states off /AP /N before the strip: PDFBox derives a button's + // value vocabulary from those keys, so a guessed state orphans /V. + List onStates = + field instanceof PDButton button ? currentWidgetOnStates(button) : List.of(); + boolean isRadio = field instanceof PDRadioButton; + + int leftOffPage = 0; + for (int i = 0; i < widgets.size(); i++) { + PDAnnotationWidget widget = widgets.get(i); + PDRectangle rect = widget.getRectangle(); + if (rect == null) { + continue; + } + if (i > 0 && determineWidgetPageIndex(document, widget, pageMap) != anchorPage) { + // A delta measured in another page's user space means nothing here. + log.warn( + "Field '{}' widget {} sits on a different page; geometry left alone", + field.getFullyQualifiedName(), + i); + leftOffPage++; + continue; + } + // Only the widget the request describes takes the new size; the rest keep theirs. + float targetW = i == 0 && newW != null ? newW : rect.getWidth(); + float targetH = i == 0 && newH != null ? newH : rect.getHeight(); + boolean resized = + Math.abs(targetW - rect.getWidth()) > GEOMETRY_EPSILON_PT + || Math.abs(targetH - rect.getHeight()) > GEOMETRY_EPSILON_PT; + widget.setRectangle( + new PDRectangle( + rect.getLowerLeftX() + dx, + rect.getLowerLeftY() + dy, + targetW, + targetH)); + // A pure translation re-maps the same /AP onto the new /Rect unchanged, but a toggle + // with no /AP at all has no on-state vocabulary and must be given one regardless. + boolean toggle = field instanceof PDCheckBox || field instanceof PDRadioButton; + if (resized || (toggle && normalAppearanceOnState(widget) == null)) { + rebuildWidgetAppearance(document, field, widget, i, onStates, isRadio); + } + } + // One row per field, not per widget, so a split group cannot fill the report on its own. + if (leftOffPage > 0) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + leftOffPage + " widget(s) on another page were left where they were"); + } + } + + /** After a resize, rebuilds the appearance PDFBox cannot regenerate by itself. */ + private void rebuildWidgetAppearance( + PDDocument document, + PDField field, + PDAnnotationWidget widget, + int index, + List onStates, + boolean isRadio) { + if (field instanceof PDSignatureField) { + // PDFBox never rebuilds a signature appearance; dropping it blanks the field. + return; + } + COSName priorState = widget.getCOSObject().getCOSName(COSName.AS); + try { + if (field instanceof PDCheckBox || field instanceof PDRadioButton) { + // Drop the stale streams: viewers stretch an /AP built for the old BBox + // onto the new /Rect, so a resized toggle looks distorted. + widget.getCOSObject().removeItem(COSName.AP); + String onState = + index < onStates.size() ? onStates.get(index) : DEFAULT_CHECKBOX_ON_STATE; + applyToggleAppearance(document, widget, onState, isRadio); + // applyToggleAppearance parks the widget on Off; put the selection back. + if (priorState != null && !OFF_STATE.equals(priorState.getName())) { + widget.getCOSObject().setName(COSName.AS, onState); + } + } else if (field instanceof PDPushButton) { + // /D and /R still carry the old BBox and cannot be regenerated, so drop them + // rather than leave a stretched down-state; viewers fall back to /N. + PDAppearanceDictionary existing = widget.getAppearance(); + if (existing != null) { + existing.getCOSObject().removeItem(COSName.D); + existing.getCOSObject().removeItem(COSName.R); + } + applyPushButtonAppearance(document, widget); + } else { + // text/choice: refreshAppearances() rebuilds these from /DA in ensureAppearances(). + widget.getCOSObject().removeItem(COSName.AP); + } + } catch (Exception e) { + log.warn( + "Could not rebuild the appearance for '{}' widget {}: {}", + field.getFullyQualifiedName(), + index, + e.getMessage()); + } + } + + /** Rewrites only the size token in a variable-text field's /DA, keeping font and colour. */ + private void applyFontSizeToDefaultAppearance(PDVariableText field, float fontSize) { + String da = field.getDefaultAppearance(); + if (da != null && !da.isBlank()) { + // Replace the size token (the operand immediately before "Tf"). + String[] tokens = da.split("\\s+"); + boolean replaced = false; + for (int i = 0; i < tokens.length; i++) { + if ("Tf".equals(tokens[i]) && i > 0) { + tokens[i - 1] = String.valueOf(fontSize); + replaced = true; + break; + } + } + if (replaced) { + field.setDefaultAppearance(String.join(" ", tokens)); + return; + } + } + field.setDefaultAppearance("/Helv " + fontSize + " Tf 0 g"); + } + + /** Drops cached /AP appearance streams from every widget of a field. */ + private void removeWidgetAppearanceStreams(PDField field) { + List widgets = field.getWidgets(); + if (widgets == null) { + return; + } + for (PDAnnotationWidget widget : widgets) { + widget.getCOSObject().removeItem(COSName.AP); + } } private String fallbackLabelForType(String type, int typeIndex) { @@ -1830,12 +2348,700 @@ public class FormUtils { return -1; } + /** + * Adds fields, creating an AcroForm if absent. Definition coordinates are CropBox-relative and + * lower-left-origin (the inverse of {@link #createWidgetCoordinates}). + */ + public void addNewFields(PDDocument document, List definitions) + throws IOException { + addNewFields(document, definitions, null); + } + + /** + * A form with variable-text fields needs /DR and /DA; PDFBox refuses to set a value without + * them, and a PDF that never had a form has neither. + */ + private void ensureAcroFormDefaults(PDAcroForm acroForm) { + if (acroForm == null) return; + try { + PDResources dr = acroForm.getDefaultResources(); + if (dr == null) { + dr = new PDResources(); + acroForm.setDefaultResources(dr); + } + String resourceName = "Helv"; + COSName alias = dr.add(new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + if (alias != null && alias.getName() != null && !alias.getName().isBlank()) { + resourceName = alias.getName(); + } + String da = acroForm.getDefaultAppearance(); + if (da == null || da.isBlank()) { + acroForm.setDefaultAppearance("/" + resourceName + " 12 Tf 0 g"); + } + } catch (Exception e) { + log.debug("Could not prepare AcroForm defaults: {}", e.getMessage()); + } + } + + public void addNewFields( + PDDocument document, + List definitions, + List skipped) + throws IOException { + if (document == null || definitions == null || definitions.isEmpty()) return; + // A page-less document has nowhere to put a widget; the clamp below cannot make it safe. + if (document.getNumberOfPages() == 0) { + log.warn("Cannot add form fields: document has no pages"); + for (NewFormFieldDefinition definition : definitions) { + if (definition != null) { + recordSkip(skipped, "add", definition.name(), "the document has no pages"); + } + } + return; + } + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + // Create a new AcroForm for PDFs that don't have one yet + acroForm = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(acroForm); + } + ensureAcroFormDefaults(acroForm); + + Set existingNames = collectExistingFieldNames(acroForm); + int pageCount = document.getNumberOfPages(); + // Buttons need their appearances built after creation; see applyButtonAppearances. + List> createdButtons = new ArrayList<>(); + + for (NewFormFieldDefinition definition : definitions) { + if (definition == null) continue; + + String nameProblem = invalidFieldNameReason(definition.name()); + if (nameProblem != null) { + log.warn("Rejecting new field: {}", nameProblem); + recordSkip(skipped, "add", definition.name(), nameProblem); + continue; + } + + String resolvedType = + Optional.ofNullable(definition.type()) + .map(FormUtils::normalizeFieldType) + .orElse(FIELD_TYPE_TEXT); + + int pageIdx = definition.pageIndex() != null ? definition.pageIndex() : 0; + if (pageIdx < 0 || pageIdx >= pageCount) { + log.warn( + "Page index {} out of range (0-{}); clamping to last page", + pageIdx, + pageCount - 1); + // Clamped, so the field IS created; not a dropped edit and not reported as one. + pageIdx = Math.max(0, pageCount - 1); + } + PDPage page; + try { + page = document.getPage(pageIdx); + } catch (RuntimeException e) { + // getNumberOfPages() reports the raw /Count, which a broken /Pages tree can + // overstate, so the page may still not be there. + recordSkip( + skipped, + "add", + definition.name(), + "page " + (pageIdx + 1) + " could not be read from this PDF"); + continue; + } + PDRectangle cropBox = page.getCropBox(); + + // CropBox-relative, lower-left-origin -> absolute PDF user space. + float x = (definition.x() != null ? definition.x() : 0f) + cropBox.getLowerLeftX(); + float y = (definition.y() != null ? definition.y() : 0f) + cropBox.getLowerLeftY(); + float w = definition.width() != null ? definition.width() : 150f; + float h = definition.height() != null ? definition.height() : 20f; + PDRectangle rectangle = new PDRectangle(x, y, w, h); + + String baseName = + Optional.ofNullable(definition.name()) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .orElse("field"); + String uniqueName = generateUniqueFieldName(baseName, existingNames); + existingNames.add(uniqueName); + + List options = sanitizeOptions(definition.options()); + + try { + if (FIELD_TYPE_RADIO.equals(resolvedType)) { + // Radio is a single field with one widget per option; it can't go + // through the single-widget createNewField path. + createRadioField(acroForm, page, rectangle, uniqueName, definition, options); + } else { + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Quietly making it a text field reported success for a field the + // caller never asked for; say so instead. + recordSkip( + skipped, + "add", + uniqueName, + "'" + resolvedType + "' fields cannot be created"); + existingNames.remove(uniqueName); + continue; + } + createNewField( + handler, acroForm, page, rectangle, uniqueName, definition, options); + } + createdButtons.add(Map.entry(uniqueName, definition)); + } catch (Exception e) { + log.warn( + "Failed to create field '{}' of type '{}': {}", + sanitizeForLog(uniqueName), + resolvedType, + e.getMessage(), + e); + recordSkip(skipped, "add", uniqueName, readableFailure(e)); + } + } + + applyButtonAppearances(document, acroForm, createdButtons); + ensureAppearances(acroForm); + } + + /** + * {@link PDAcroForm#refreshAppearances()} never synthesizes /AP for the button family, and + * PDFBox reads a button's on-state from the /AP /N keys, so draw them then re-apply the value. + */ + private void applyButtonAppearances( + PDDocument document, + PDAcroForm acroForm, + List> created) { + for (Map.Entry entry : created) { + PDField field = acroForm.getField(entry.getKey()); + if (field instanceof PDPushButton) { + for (PDAnnotationWidget widget : field.getWidgets()) { + try { + applyPushButtonAppearance(document, widget); + } catch (Exception e) { + log.warn( + "Could not build an appearance for button '{}': {}", + entry.getKey(), + e.getMessage()); + } + } + continue; + } + if (!(field instanceof PDCheckBox) && !(field instanceof PDRadioButton)) { + continue; + } + boolean isRadio = field instanceof PDRadioButton; + List onStates = buttonOnStates((PDButton) field); + List widgets = field.getWidgets(); + for (int i = 0; i < widgets.size(); i++) { + String onState = i < onStates.size() ? onStates.get(i) : DEFAULT_CHECKBOX_ON_STATE; + try { + applyToggleAppearance(document, widgets.get(i), onState, isRadio); + } catch (Exception e) { + log.warn( + "Could not build an appearance for '{}' widget {}: {}", + entry.getKey(), + i, + e.getMessage()); + } + } + applyButtonDefault((PDButton) field, entry.getValue(), onStates); + } + } + + /** The on-state per widget: the export values when set, else the single checkbox state. */ + private List buttonOnStates(PDButton button) { + List exportValues = button.getExportValues(); + if (exportValues != null && !exportValues.isEmpty()) { + return exportValues; + } + return List.of(DEFAULT_CHECKBOX_ON_STATE); + } + + /** Each widget's live on-state, read from /AP /N before that dictionary is dropped. */ + private List currentWidgetOnStates(PDButton button) { + List exportValues = button.getExportValues(); + List widgets = button.getWidgets(); + // A checkbox's widgets all share one on-state, so /V names it however many there are. + // A radio's widgets each have their own, so /V identifies one and cannot stand in. + boolean sharedOnState = !(button instanceof PDRadioButton); + String fromValue = sharedOnState || widgets.size() == 1 ? nonOffValueName(button) : null; + List states = new ArrayList<>(widgets.size()); + for (int i = 0; i < widgets.size(); i++) { + String state = normalAppearanceOnState(widgets.get(i)); + if ((state == null || state.isEmpty()) + && exportValues != null + && i < exportValues.size()) { + state = sanitizePdfName(exportValues.get(i)); + } + if (state == null || state.isEmpty()) { + state = fromValue; + } + states.add(state == null || state.isEmpty() ? DEFAULT_CHECKBOX_ON_STATE : state); + } + return states; + } + + /** A button's current value when it names a real on-state, else null. */ + private String nonOffValueName(PDButton button) { + try { + // /V is inheritable, so walk up. A malformed PDF can point /Parent back at an + // ancestor, so track what we have seen rather than trusting the chain to end. + COSBase raw = null; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + COSDictionary d = button.getCOSObject(); + while (d != null && raw == null && seen.add(d)) { + raw = d.getDictionaryObject(COSName.V); + COSBase parent = d.getDictionaryObject(COSName.PARENT); + d = parent instanceof COSDictionary parentDict ? parentDict : null; + } + String name = + switch (raw) { + case COSName cosName -> cosName.getName(); + case COSString cosString -> cosString.getString(); + case null, default -> null; + }; + return name == null || name.isEmpty() || OFF_STATE.equals(name) ? null : name; + } catch (Exception e) { + log.debug("Could not read a button's value: {}", e.getMessage()); + return null; + } + } + + /** The first non-Off key of a widget's /AP /N sub-dictionary, or null. */ + private String normalAppearanceOnState(PDAnnotationWidget widget) { + try { + PDAppearanceDictionary appearance = widget.getAppearance(); + PDAppearanceEntry normal = appearance != null ? appearance.getNormalAppearance() : null; + if (normal == null || !normal.isSubDictionary()) { + return null; + } + for (COSName name : normal.getSubDictionary().keySet()) { + if (!OFF_STATE.equals(name.getName())) { + return name.getName(); + } + } + } catch (Exception e) { + log.debug("Could not read a widget's on-state: {}", e.getMessage()); + } + return null; + } + + /** Re-applies the definition's default now that the on-state keys exist to resolve it. */ + private void applyButtonDefault( + PDButton button, NewFormFieldDefinition definition, List onStates) { + try { + if (button instanceof PDCheckBox checkBox) { + if (isChecked(definition.defaultValue())) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + return; + } + String requested = definition.defaultValue(); + if (requested == null || requested.isBlank()) { + return; + } + // The widget states are sanitized PDF names, so match the raw request against those. + String match = + onStates.stream() + .filter( + state -> + state.equals(requested) + || state.equalsIgnoreCase( + sanitizePdfName(requested))) + .findFirst() + .orElse(null); + if (match != null) { + button.setValue(match); + } + } catch (Exception e) { + log.debug( + "Could not apply default value for '{}': {}", + button.getPartialName(), + e.getMessage()); + } + } + + /** + * Builds a toggle's two-state /AP /N from drawing primitives, so no font resource is needed. + */ + private void applyToggleAppearance( + PDDocument document, PDAnnotationWidget widget, String onState, boolean isRadio) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + PDRectangle bbox = new PDRectangle(w, h); + + PDAppearanceDictionary appearance = new PDAppearanceDictionary(); + COSDictionary normalStates = new COSDictionary(); + normalStates.setItem( + COSName.getPDFName(OFF_STATE), + toggleStream(document, bbox, false, false).getCOSObject()); + normalStates.setItem( + COSName.getPDFName(onState), + toggleStream(document, bbox, true, isRadio).getCOSObject()); + appearance.getCOSObject().setItem(COSName.N, normalStates); + widget.setAppearance(appearance); + // Until a value selects it, the widget shows the Off appearance. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + } + + private PDAppearanceStream toggleStream( + PDDocument document, PDRectangle bbox, boolean on, boolean isRadio) throws IOException { + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(bbox); + stream.setResources(new PDResources()); + + float w = bbox.getWidth(); + float h = bbox.getHeight(); + float inset = Math.min(w, h) * 0.1f; + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setStrokingColor(0f, 0f, 0f); + content.setNonStrokingColor(0f, 0f, 0f); + content.setLineWidth(Math.max(0.5f, Math.min(w, h) * 0.06f)); + if (isRadio) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 2 - inset); + content.stroke(); + if (on) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 4 - inset / 2); + content.fill(); + } + } else { + content.addRect(inset, inset, w - 2 * inset, h - 2 * inset); + content.stroke(); + if (on) { + content.moveTo(w * 0.25f, h * 0.5f); + content.lineTo(w * 0.45f, h * 0.28f); + content.lineTo(w * 0.78f, h * 0.72f); + content.stroke(); + } + } + } + return stream; + } + + /** + * Draws a push button's single {@code /AP /N} stream from its {@code /MK} characteristics. + * PDFBox never synthesizes one, so without this a push button has no appearance at all. + */ + private void applyPushButtonAppearance(PDDocument document, PDAnnotationWidget widget) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(new PDRectangle(w, h)); + stream.setResources(new PDResources()); + + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + String caption = mk != null ? mk.getNormalCaption() : null; + // Honour the authored /MK colours; a hardcoded grey would restyle an existing button. + float[] background = mkColour(mk == null ? null : mk.getBackground(), 0.85f); + float[] border = mkColour(mk == null ? null : mk.getBorderColour(), 0f); + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + float fontSize = Math.min(12f, h * 0.6f); + + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setNonStrokingColor(background[0], background[1], background[2]); + content.addRect(0, 0, w, h); + content.fill(); + content.setStrokingColor(border[0], border[1], border[2]); + content.setLineWidth(1f); + content.addRect(0.5f, 0.5f, w - 1f, h - 1f); + content.stroke(); + if (caption != null && !caption.isBlank()) { + try { + float textWidth = font.getStringWidth(caption) / 1000f * fontSize; + content.beginText(); + content.setFont(font, fontSize); + content.setNonStrokingColor(0f, 0f, 0f); + content.newLineAtOffset( + Math.max(2f, (w - textWidth) / 2f), + (h - fontSize) / 2f + fontSize * 0.2f); + content.showText(caption); + content.endText(); + } catch (Exception e) { + // Unencodable caption: keep the frame, drop the text. + log.debug("Could not draw button caption '{}': {}", caption, e.getMessage()); + } + } + } + + // Reuse the existing dictionary so an authored /D or /R is not collateral damage. + PDAppearanceDictionary appearance = widget.getAppearance(); + if (appearance == null) { + appearance = new PDAppearanceDictionary(); + widget.setAppearance(appearance); + } + appearance.setNormalAppearance(stream); + // A push button has no value, so no /AS. + widget.getCOSObject().removeItem(COSName.AS); + } + + /** A /MK colour array as RGB, falling back to a grey level when absent or unsupported. */ + private float[] mkColour(PDColor colour, float fallback) { + float[] rgb = {fallback, fallback, fallback}; + if (colour == null) { + return rgb; + } + float[] components; + try { + components = colour.getComponents(); + } catch (Exception e) { + log.debug("Unreadable /MK colour: {}", e.getMessage()); + return rgb; + } + if (components.length == 3) { + rgb = components.clone(); + } else if (components.length == 1) { + rgb = new float[] {components[0], components[0], components[0]}; + } else if (components.length == 4) { + // CMYK to RGB, good enough for button chrome. + float k = components[3]; + rgb = + new float[] { + (1 - components[0]) * (1 - k), + (1 - components[1]) * (1 - k), + (1 - components[2]) * (1 - k) + }; + } + // PDPageContentStream rejects anything outside 0..1, and a throw here loses the appearance. + for (int i = 0; i < rgb.length; i++) { + rgb[i] = Math.min(1f, Math.max(0f, rgb[i])); + } + return rgb; + } + + /** A circle from four Bezier arcs; PDF has no primitive for one. */ + private void drawCircle(PDPageContentStream content, float cx, float cy, float r) + throws IOException { + if (r <= 0) { + return; + } + float k = r * 0.5523f; + content.moveTo(cx - r, cy); + content.curveTo(cx - r, cy + k, cx - k, cy + r, cx, cy + r); + content.curveTo(cx + k, cy + r, cx + r, cy + k, cx + r, cy); + content.curveTo(cx + r, cy - k, cx + k, cy - r, cx, cy - r); + content.curveTo(cx - k, cy - r, cx - r, cy - k, cx - r, cy); + content.closePath(); + } + + /** + * One widget per option stacked below {@code baseRect}, each keyed by its sanitized export + * value so the group behaves as a single selectable field. + */ + /** + * Per-option widget rects laid out INSIDE the drawn box, which is the group's total extent. + * Stacking outside it made a three-option group three times taller than what was drawn. + */ + public static List radioOptionRects( + PDRectangle box, int count, Float gapOverride, Float sizeOverride) { + List rects = new ArrayList<>(); + int n = Math.max(1, count); + float h = box.getHeight(); + float slot = h / n; + + float size; + if (sizeOverride != null && sizeOverride > 0f) { + size = sizeOverride; + } else if (gapOverride != null && gapOverride >= 0f) { + size = (h - (n - 1) * gapOverride) / n; + } else { + // A quarter of each slot is breathing room, so the stack fills the drawn height. + size = slot * 0.75f; + } + // Square keeps the circle round; a wide box becomes a left-aligned column. + size = Math.max(1f, Math.min(size, box.getWidth())); + + float gap; + if (gapOverride != null && gapOverride >= 0f) { + gap = gapOverride; + } else { + gap = n > 1 ? Math.max(0f, (h - n * size) / (n - 1)) : 0f; + } + + float top = box.getLowerLeftY() + h; + for (int i = 0; i < n; i++) { + float y = top - (i + 1) * size - i * gap; + rects.add(new PDRectangle(box.getLowerLeftX(), y, size, size)); + } + return rects; + } + + private void createRadioField( + PDAcroForm acroForm, + PDPage page, + PDRectangle baseRect, + String name, + NewFormFieldDefinition definition, + List options) + throws IOException { + + List values = (options == null || options.isEmpty()) ? List.of("1", "2") : options; + + PDRadioButton radio = new PDRadioButton(acroForm); + radio.setPartialName(name); + if (definition.label() != null && !definition.label().isBlank()) { + try { + radio.setAlternateFieldName(definition.label()); + } catch (Exception ignore) { + // alternate name is best-effort + } + } + radio.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + radio.setReadOnly(true); + } + + List optionRects = + radioOptionRects( + baseRect, values.size(), definition.optionGap(), definition.optionSize()); + + List widgets = new ArrayList<>(); + List exportValues = new ArrayList<>(); + Set usedStates = new HashSet<>(); + for (int i = 0; i < values.size(); i++) { + String onState = sanitizeOnState(values.get(i), i, usedStates); + exportValues.add(onState); + + PDRectangle rect = optionRects.get(i); + + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rect); + widget.setPage(page); + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + widget.setParent(radio); + // The widget's appearance state is "Off" until the group value selects it. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + widgets.add(widget); + + List annotations = page.getAnnotations(); + if (annotations == null) { + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + annotations.add(widget); + } + + radio.setWidgets(widgets); + try { + radio.setExportValues(exportValues); + } catch (Exception e) { + log.debug("Unable to set radio export values for '{}': {}", name, e.getMessage()); + } + + String defaultValue = definition.defaultValue(); + if (defaultValue != null + && !defaultValue.isBlank() + && exportValues.contains(defaultValue)) { + try { + radio.setValue(defaultValue); + } catch (Exception e) { + log.debug("Unable to set radio default '{}': {}", defaultValue, e.getMessage()); + } + } + + acroForm.getFields().add(radio); + } + + /** Builds a unique, PDF-name-safe "on" state for a radio widget. */ + private String sanitizeOnState(String raw, int index, Set used) { + String base = + Optional.ofNullable(raw) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(FormUtils::sanitizePdfName) + .orElse("Option" + (index + 1)); + if (OFF_STATE.equalsIgnoreCase(base)) { + base = "Option" + (index + 1); + } + String candidate = base; + int suffix = 1; + while (!used.add(candidate)) { + candidate = base + "_" + suffix++; + } + return candidate; + } + + /** Reduces a label to characters that are safe inside a PDF name. */ + private static String sanitizePdfName(String raw) { + return raw == null ? "" : raw.trim().replaceAll("[^A-Za-z0-9_-]", "_"); + } + + /** Modify, then delete, then add, so generated names dedupe against the surviving set. */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes) + throws IOException { + applyFieldEdits(document, adds, modifies, deletes, null); + } + + /** + * As above, but records every operation that could not be applied into {@code skipped} so the + * caller can report "3 of 4" instead of a bare success. + */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes, + List skipped) + throws IOException { + if (document == null) return; + if (modifies != null && !modifies.isEmpty()) { + modifyFormFields(document, modifies, skipped); + } + if (deletes != null && !deletes.isEmpty()) { + deleteFormFields(document, deletes, skipped); + } + if (adds != null && !adds.isEmpty()) { + addNewFields(document, adds, skipped); + } + } + + /** Adds an entry to a skip list that may be absent, so call sites stay one-liners. */ + private void recordSkip( + List skipped, String operation, String target, String reason) { + if (skipped != null) { + skipped.add(new SkippedFieldEdit(operation, target, reason)); + } + } + public void deleteFormFields(PDDocument document, List fieldNames) { + deleteFormFields(document, fieldNames, null); + } + + public void deleteFormFields( + PDDocument document, List fieldNames, List skipped) { if (document == null || fieldNames == null || fieldNames.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot delete fields because the document has no AcroForm"); + for (String name : fieldNames) { + recordSkip(skipped, "delete", name, "the document has no form to delete from"); + } return; } @@ -1847,6 +3053,7 @@ public class FormUtils { PDField field = locateField(acroForm, name.trim()); if (field == null) { log.warn("No matching field '{}' found for deletion", name); + recordSkip(skipped, "delete", name, "no field with that name exists"); continue; } @@ -2138,17 +3345,122 @@ public class FormUtils { return Collections.emptySet(); } Set existing = new HashSet<>(); + // Group (non-terminal) names occupy the namespace too, so a new field must not be + // allowed to take one; omitting them hides a whole class of collision. for (PDField field : acroForm.getFieldTree()) { - if (field instanceof PDTerminalField) { - String fqn = field.getFullyQualifiedName(); - if (fqn != null && !fqn.isEmpty()) { - existing.add(fqn); - } + String fqn = field.getFullyQualifiedName(); + if (fqn != null && !fqn.isEmpty()) { + existing.add(fqn); } } return existing; } + /** A text field's /MaxLen, or null when unset so the editor shows an empty box. */ + private Integer extractMaxLength(PDField field) { + if (field instanceof PDTextField textField) { + int maxLen = textField.getMaxLen(); + return maxLen > 0 ? maxLen : null; + } + return null; + } + + /** Reads a push button's action back into the same spec string the editor sends. */ + private String extractButtonAction(PDField field) { + if (!(field instanceof PDPushButton)) { + return null; + } + for (PDAnnotationWidget widget : field.getWidgets()) { + COSBase raw = widget.getCOSObject().getDictionaryObject(COSName.A); + if (!(raw instanceof COSDictionary action)) { + continue; + } + String subtype = action.getNameAsString(COSName.S); + if ("ResetForm".equals(subtype)) { + return "reset"; + } + if ("Named".equals(subtype)) { + return "Print".equalsIgnoreCase(action.getNameAsString(COSName.N)) ? "print" : null; + } + if ("URI".equals(subtype)) { + return "uri:" + Optional.ofNullable(action.getString(COSName.URI)).orElse(""); + } + if ("SubmitForm".equals(subtype)) { + return "submit:" + Optional.ofNullable(action.getString(COSName.F)).orElse(""); + } + } + return null; + } + + /** The parent prefix of a qualified name, including the trailing dot, or "" if top level. */ + private String parentPrefix(String qualifiedName) { + int dot = qualifiedName == null ? -1 : qualifiedName.lastIndexOf('.'); + return dot < 0 ? "" : qualifiedName.substring(0, dot + 1); + } + + /** + * The partial name a rename should set. Only a new name under the target's own parent may be + * qualified; anything else is used verbatim so it cannot silently re-parent the field. + */ + private String leafName(String targetName, String newName) { + String prefix = parentPrefix(targetName); + return !prefix.isEmpty() && newName.startsWith(prefix) + ? newName.substring(prefix.length()) + : newName; + } + + /** + * Why the rename is impossible, or null. An unchanged name is not a rename, so a field nested + * under a parent is not rejected for the period in its qualified name. + */ + public String renameProblem(String targetName, String newName) { + if (newName == null || newName.equals(targetName)) { + return null; + } + // A nested field's box shows "Parent.Child", so renaming the leaf under the same + // parent is legitimate; only the leaf has to be a storable partial name. + String trimmed = newName.trim(); + String leaf = leafName(targetName, trimmed); + if (!trimmed.isEmpty() && leaf.isBlank()) { + return "Field name '" + newName + "' has no name after the parent prefix."; + } + return invalidFieldNameReason(leaf); + } + + /** + * Why {@code name} is unusable as a field name, or null when it is fine. AcroForm reserves the + * period as the parent/child separator, so PDFBox rejects it outright in a partial name. + */ + public String invalidFieldNameReason(String name) { + if (name == null || name.isBlank()) { + return null; + } + if (name.chars().anyMatch(Character::isISOControl)) { + // A line break in a name would also forge a second line in every log it reaches. + return "Field name cannot contain line breaks or control characters."; + } + if (name.indexOf('.') >= 0) { + return "Field name '" + + sanitizeForLog(name) + + "' cannot contain a period. PDF forms use '.' to separate a parent field" + + " from its children."; + } + return null; + } + + /** + * A caller-supplied string made safe to log. Without this a name containing CR/LF writes an + * extra, attacker-chosen line into the log file (CWE-117). + */ + public static String sanitizeForLog(String value) { + if (value == null) { + return null; + } + StringBuilder out = new StringBuilder(value.length()); + value.chars().forEach(c -> out.append(Character.isISOControl(c) ? ' ' : (char) c)); + return out.toString(); + } + private PDField locateField(PDAcroForm acroForm, String name) { if (acroForm == null || name == null) { return null; @@ -2185,20 +3497,26 @@ public class FormUtils { } private String generateUniqueFieldName(String baseName, Set existingNames) { - String sanitized = + return generateUniqueFieldName(baseName, existingNames, ""); + } + + /** + * A partial name no sibling already uses. {@code qualifiedPrefix} is prepended only for the + * collision check, because {@code existingNames} holds fully qualified names. + */ + private String generateUniqueFieldName( + String baseName, Set existingNames, String qualifiedPrefix) { + // Trimmed, not sanitized: callers must reject bad names first via invalidFieldNameReason. + String trimmed = Optional.ofNullable(baseName) .map(String::trim) .filter(s -> !s.isEmpty()) .orElse("field"); - StringBuilder candidateBuilder = new StringBuilder(sanitized); - String candidate = candidateBuilder.toString(); + String candidate = trimmed; int counter = 1; - - while (existingNames.contains(candidate)) { - candidateBuilder.setLength(0); - candidateBuilder.append(sanitized).append("_").append(counter); - candidate = candidateBuilder.toString(); + while (existingNames.contains(qualifiedPrefix + candidate)) { + candidate = trimmed + "_" + counter; counter++; } @@ -2235,9 +3553,29 @@ public class FormUtils { } } field.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + field.setReadOnly(true); + } - PDAnnotationWidget widget = - existingWidget != null ? existingWidget : new PDAnnotationWidget(); + // A terminal field with no /Kids shares its dictionary with one merged widget. + // A separately built widget is not linked via /Kids, so its /Rect is lost on save. + boolean reuseFieldDict; + PDAnnotationWidget widget; + if (existingWidget != null) { + widget = existingWidget; + reuseFieldDict = false; + } else { + List current = field.getWidgets(); + if (current != null && !current.isEmpty()) { + widget = current.get(0); + } else { + widget = new PDAnnotationWidget(); + } + reuseFieldDict = widget.getCOSObject() == field.getCOSObject(); + // Make sure the shared dictionary is recognised as a widget annotation. + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + } // Ensure rectangle is valid and set before any appearance-related operations // please note removal of this might cause **subtle** issues @@ -2250,10 +3588,10 @@ public class FormUtils { } widget.setRectangle(validRectangle); widget.setPage(page); - - if (existingWidget == null) { - widget.setPrinted(true); - } + // Explicitly set the /P entry so the widget keeps a valid page reference + // after save/reload (some viewers rely on it to resolve the widget page). + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.setPrinted(true); if (definition.tooltip() != null && !definition.tooltip().isBlank()) { widget.getCOSObject().setString(COSName.TU, definition.tooltip()); @@ -2265,13 +3603,25 @@ public class FormUtils { } } - field.getWidgets().add(widget); - widget.setParent(field); + // Only link a SEPARATE widget into the field; the merged widget IS the + // field dictionary and is already its own widget. + if (!reuseFieldDict) { + List widgets = new ArrayList<>(field.getWidgets()); + if (!widgets.contains(widget)) { + widgets.add(widget); + field.setWidgets(widgets); + } + widget.setParent(field); + } List annotations = page.getAnnotations(); if (annotations == null) { - page.getAnnotations().add(widget); - } else if (!annotations.contains(widget)) { + // page.getAnnotations() can return null; calling it again and adding + // would NPE. Initialise the list and attach it to the page first. + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + if (!annotations.contains(widget)) { annotations.add(widget); } acroForm.getFields().add(field); @@ -2399,7 +3749,60 @@ public class FormUtils { Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; derived from the drawn box when null. */ + Float optionGap, + /** Radio option size in points; derived from the drawn box when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to derived. */ + public NewFormFieldDefinition( + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } @JsonInclude(JsonInclude.Include.NON_NULL) public record ModifyFormFieldDefinition( @@ -2407,11 +3810,120 @@ public class FormUtils { String name, String label, String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, Boolean required, Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; leaves the existing layout alone when null. */ + Float optionGap, + /** Radio option size in points; leaves the existing layout alone when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to unchanged. */ + public ModifyFormFieldDefinition( + String targetName, + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + targetName, + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } + + /** A mixed batch of field edits applied in one request via {@link #applyFieldEdits}. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FieldEditBatch( + List add, + List modify, + List delete) {} + + /** + * One requested edit the document could not take, so the caller can report "3 of 4" rather than + * a bare success. {@code operation} is "add", "modify" or "delete". + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + /** + * Turns a library failure into something a person can act on. Raw messages like "/DR is a + * required entry" name PDF internals the user has never heard of. + */ + public static String readableFailure(Throwable failure) { + String raw = failure == null ? null : failure.getMessage(); + if (raw == null || raw.isBlank()) { + return "this PDF would not accept the change"; + } + String lower = raw.toLowerCase(java.util.Locale.ROOT); + if (lower.contains("/dr") || lower.contains("default resources")) { + return "this PDF's form has no font settings, so the field could not be styled"; + } + if (lower.contains("font") && lower.contains("not")) { + return "the font this field asks for is not embedded in the PDF"; + } + if (lower.contains("encrypt") || lower.contains("password")) { + return "the PDF is protected, so its form cannot be changed"; + } + if (lower.contains("read-only") || lower.contains("readonly")) { + return "the field is read-only in this PDF"; + } + // Anything unrecognised stays vague rather than leaking internals at the user. + log.debug("Unmapped form edit failure: {}", raw); + return "this PDF would not accept the change"; + } + + /** Skip reasons travel in a response header, so an echoed value cannot be unbounded. */ + public static String abbreviate(String value, int max) { + if (value == null || value.length() <= max) { + return value; + } + return value.substring(0, max) + "..."; + } + + public record SkippedFieldEdit(String operation, String target, String reason) {} @JsonInclude(JsonInclude.Include.NON_NULL) public record FormFieldInfo( @@ -2433,19 +3945,25 @@ public class FormUtils { static final class FieldCoordinateComparator implements Comparator { private static int firstWidgetPageIndex(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getPageIndex() : -1; } private static float firstWidgetY(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getY() : 0; } private static float firstWidgetX(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getX() : 0; } diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index f85880df5d..bfd3f53c67 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -4,7 +4,14 @@ import java.util.regex.Pattern; public class RequestUriUtils { - private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$"); + // Share tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly + private static final Pattern SHARE_LINK_PATTERN = + Pattern.compile( + "^/share/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$"); + // Invite tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly + private static final Pattern INVITE_LINK_PATTERN = + Pattern.compile( + "^/invite/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$"); public static boolean isStaticResource(String requestURI) { return isStaticResource("", requestURI); @@ -69,7 +76,7 @@ public class RequestUriUtils { // cookie, so the server can't authenticate the navigation itself). The // portal gates access via its own auth gate + RequirePortalAccess, and its // data APIs stay protected, so serving the shell pre-auth is safe. - if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) { + if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) { return true; } @@ -209,7 +216,9 @@ public class RequestUriUtils { // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") // Share-link SPA bootstrap; data APIs remain protected - || SHARE_LINK_PATTERN.matcher(trimmedUri).matches(); + || SHARE_LINK_PATTERN.matcher(trimmedUri).matches() + // Invite-accept SPA bootstrap; data APIs remain protected + || INVITE_LINK_PATTERN.matcher(trimmedUri).matches(); } private static String stripContextPath(String contextPath, String requestURI) { diff --git a/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java new file mode 100644 index 0000000000..b5312576e4 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java @@ -0,0 +1,116 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSObject; +import org.apache.pdfbox.cos.COSObjectKey; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Pins how a choice field's options survive a save, which real forms rely on. */ +class ChoiceOptionRoundTripTest { + + private static PDComboBox combo(PDDocument document, List options) throws IOException { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + PDComboBox field = new PDComboBox(form); + field.setPartialName("state"); + field.setOptions(options); + form.getFields().add(field); + return field; + } + + @Test + @DisplayName("a whitespace-only option survives a load, save and reload") + void whitespaceOptionSurvivesRoundTrip() throws IOException { + List options = List.of(" ", "Alabama", "Alaska"); + + byte[] first; + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + combo(document, options); + document.save(out); + first = out.toByteArray(); + } + // The real path edits a document loaded from bytes, not one built in memory. + byte[] saved; + try (PDDocument loaded = Loader.loadPDF(first); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + loaded.save(out); + saved = out.toByteArray(); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDComboBox reread = + (PDComboBox) reloaded.getDocumentCatalog().getAcroForm(null).getField("state"); + assertEquals( + options, + reread.getOptionsExportValues(), + "an option must not vanish because the writer made it indirect"); + } + } + + @Test + @DisplayName("an option stored as an indirect reference is still reported") + void indirectOptionIsStillReported() throws IOException { + try (PDDocument document = new PDDocument()) { + PDComboBox field = combo(document, List.of(" ", "Alabama")); + + // Real forms reference option strings indirectly; the reader must follow the reference. + COSArray options = new COSArray(); + options.add(new COSObject(new COSString(" "), new COSObjectKey(629, 0))); + options.add(new COSString("Alabama")); + field.getCOSObject().setItem(COSName.OPT, options); + + // Every read path runs this repair first, which is where the reference is followed. + FormUtils.repairMissingWidgetPageReferences(document); + + assertEquals( + List.of(" ", "Alabama"), + field.getOptionsExportValues(), + "an indirectly stored option must not be dropped"); + } + } + + @Test + @DisplayName("a signature field reports no value rather than a JVM identity hash") + void signatureValueIsNotAnIdentityHash() throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + PDSignatureField signature = new PDSignatureField(form); + signature.setPartialName("approval"); + // Only a field that actually holds a signature hits getValueAsString's toString(). + signature.setValue(new PDSignature()); + form.getFields().add(signature); + + List fields = FormUtils.extractFormFields(document); + + FormUtils.FormFieldInfo field = + fields.stream() + .filter(f -> "approval".equals(f.name())) + .findFirst() + .orElseThrow(); + // An identity hash differs per load, so the same document would describe itself twice. + assertNull(field.value(), "a signature has no text value"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java new file mode 100644 index 0000000000..4c28952ff0 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java @@ -0,0 +1,62 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A hostile or corrupt form must fail as a rejected request, never as a crashed thread. */ +class DeepFieldTreeTest { + + private static byte[] chainOfKids(int depth) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + + COSDictionary root = new COSDictionary(); + root.setString(COSName.T, "n0"); + COSDictionary cursor = root; + for (int i = 1; i < depth; i++) { + COSDictionary kid = new COSDictionary(); + kid.setString(COSName.T, "n" + i); + kid.setItem(COSName.PARENT, cursor); + COSArray kids = new COSArray(); + kids.add(kid); + cursor.setItem(COSName.KIDS, kids); + cursor = kid; + } + cursor.setItem(COSName.FT, COSName.getPDFName("Tx")); + + COSArray fields = new COSArray(); + fields.add(root); + form.getCOSObject().setItem(COSName.FIELDS, fields); + document.save(out); + return out.toByteArray(); + } + } + + @Test + @DisplayName("a deeply nested field tree extracts without overflowing the stack") + void deepKidsChainDoesNotOverflow() throws IOException { + // 2000 is as deep as PDFBox's own writer can build here; beyond that the overflow is in + // the writer, not in extraction, so it is not something a read endpoint would hit. + byte[] pdf = chainOfKids(2000); + + try (PDDocument document = Loader.loadPDF(pdf)) { + assertDoesNotThrow(() -> FormUtils.extractFormFieldsWithCoordinates(document)); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java new file mode 100644 index 0000000000..1f213c15ba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java @@ -0,0 +1,201 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.FormFieldWithCoordinates; + +/** An edit that cannot be honoured must be refused and reported, never silently reshaped. */ +class FormEditSafetyTest { + + private static PDDocument formWith(String name, String type) throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.A4)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + name, + null, + type, + 0, + 50f, + 700f, + 200f, + 20f, + null, + null, + type.equals("radio") ? List.of("a", "b") : null, + null, + null, + null, + null, + null, + null, + null))); + return document; + } + + private static FormUtils.ModifyFormFieldDefinition modify( + String target, String type, Float width, Float height) { + // Order: targetName, name, label, type, pageIndex, x, y, width, height, then the rest. + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, type, null, null, null, width, height, null, null, null, null, + null, null, null, null, null, null); + } + + @Test + @DisplayName("a type that cannot be rebuilt is refused instead of becoming a text field") + void unrebuildableTypeIsRefused() throws IOException { + try (PDDocument document = formWith("choice", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("choice", "radio", null, null)), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("choice"); + assertFalse(skipped.isEmpty(), "the refusal must be reported to the caller"); + assertFalse( + field instanceof PDRadioButton, + "it could not become a radio, so it must not claim to be one"); + assertEquals( + "text", + FormUtils.extractFormFields(document).getFirst().type(), + "the original field must survive untouched rather than be retyped"); + } + } + + @Test + @DisplayName("a field rebuilt as a checkbox gets an appearance so it can be ticked") + void rebuiltCheckboxIsUsable() throws IOException { + try (PDDocument document = formWith("agree", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("agree", "checkbox", null, null)), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("agree"); + assertTrue(field instanceof PDCheckBox, "the rebuild should have produced a checkbox"); + assertNotNull( + field.getWidgets().getFirst().getAppearance(), + "without an appearance the checkbox renders blank and cannot be ticked"); + } + } + + @Test + @DisplayName("a size of zero or infinity is refused rather than written into the page") + void unusableSizeIsRefused() throws IOException { + for (Float bad : new Float[] {0f, -5f, Float.POSITIVE_INFINITY, Float.NaN}) { + try (PDDocument document = formWith("box", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("box", null, bad, 20f)), skipped); + + PDRectangle rect = + document.getDocumentCatalog() + .getAcroForm(null) + .getField("box") + .getWidgets() + .getFirst() + .getRectangle(); + assertFalse(skipped.isEmpty(), "a refused resize must be reported: width " + bad); + assertEquals( + 200f, + rect.getWidth(), + 0.01f, + "the original size must survive: width " + bad); + } + } + } + + @Test + @DisplayName("a widget off the page still reports its geometry instead of dropping the field") + void offPageWidgetKeepsItsGeometry() throws IOException { + try (PDDocument document = formWith("stray", "text")) { + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("stray"); + // Above the page top: legal PDF, and the user needs the coordinates to drag it back. + field.getWidgets().getFirst().setRectangle(new PDRectangle(50f, 2000f, 200f, 20f)); + + List fields = + FormUtils.extractFormFieldsWithCoordinates(document); + + FormFieldWithCoordinates stray = + fields.stream() + .filter(f -> "stray".equals(f.getName())) + .findFirst() + .orElseThrow(); + assertNotNull(stray.getWidgets(), "the field must keep its widget list"); + assertFalse(stray.getWidgets().isEmpty(), "the off-page widget must still be reported"); + assertNotNull(stray.getWidgets().getFirst(), "a null entry would crash the overlay"); + } + } + + private static FormUtils.ModifyFormFieldDefinition withValue(String target, String value) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, null, null, null, null, null, null, null, null, value, + null, null, null, null, null, null); + } + + private static FormUtils.ModifyFormFieldDefinition withOptions( + String target, List options) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, null, null, null, null, null, null, null, options, null, + null, null, null, null, null, null); + } + + @Test + @DisplayName("a value a radio group cannot hold does not destroy the group") + void badRadioValueLeavesTheGroupIntact() throws IOException { + try (PDDocument document = formWith("plan", "radio")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(withValue("plan", "not-an-option")), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan"); + assertTrue( + field instanceof PDRadioButton, + "a rejected value must not turn the group into another kind of field"); + assertEquals( + 2, + field.getWidgets().size(), + "the group's options must survive a rejected value"); + assertFalse(skipped.isEmpty(), "the caller must be told the value was not applied"); + } + } + + @Test + @DisplayName("editing a radio group's options is either applied or reported, never ignored") + void radioOptionEditIsNotSilentlyDropped() throws IOException { + try (PDDocument document = formWith("plan", "radio")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(withOptions("plan", List.of("a", "b", "c"))), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan"); + boolean applied = field.getWidgets().size() == 3; + assertTrue( + applied || !skipped.isEmpty(), + "a change the UI shows as saved must either happen or be reported as skipped"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java new file mode 100644 index 0000000000..c1b0c806e1 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java @@ -0,0 +1,61 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * A field name is caller-supplied and reaches several loggers. A line break in one would forge a + * second log line (CWE-117), so names carrying control characters are refused outright. + */ +class FormFieldNameSafetyTest { + + @Test + void aNameWithCrLfIsRefused() { + String forged = "evil\r\n2026-01-01 00:00:00 ERROR admin login from 1.2.3.4"; + String reason = FormUtils.invalidFieldNameReason(forged); + assertNotNull(reason, "a name containing CR/LF must be refused"); + assertFalse(reason.contains("\n"), "the refusal itself must not carry a line break"); + assertFalse(reason.contains("\r"), "the refusal itself must not carry a carriage return"); + } + + @Test + void otherControlCharactersAreRefusedToo() { + assertNotNull(FormUtils.invalidFieldNameReason("tab\there")); + assertNotNull(FormUtils.invalidFieldNameReason("null\u0000byte")); + } + + @Test + void ordinaryNamesStillPass() { + assertNull(FormUtils.invalidFieldNameReason("Full Name")); + assertNull(FormUtils.invalidFieldNameReason("weird/[]{}")); + assertNull(FormUtils.invalidFieldNameReason("Mr Smith")); + } + + @Test + void thePeriodRefusalDoesNotEchoControlCharacters() { + // Both problems at once: the period branch must not leak the raw name into a log line. + String reason = FormUtils.invalidFieldNameReason("Customer.Name\r\nFORGED"); + assertNotNull(reason); + assertFalse(reason.contains("\r") || reason.contains("\n"), "no raw line break: " + reason); + } + + @Test + void sanitizeForLogFlattensControlCharacters() { + assertEquals("a b", FormUtils.sanitizeForLog("a\nb")); + assertEquals("a b", FormUtils.sanitizeForLog("a\rb")); + assertEquals("plain", FormUtils.sanitizeForLog("plain")); + assertNull(FormUtils.sanitizeForLog(null)); + } + + @Test + void aPeriodIsStillRefusedWithTheOffendingCharacterNamed() { + String reason = FormUtils.invalidFieldNameReason("Customer.Name"); + assertNotNull(reason); + assertTrue(reason.contains("period"), "the message should name the problem: " + reason); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java index 771ce89659..6924764a65 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java @@ -130,13 +130,15 @@ class FormFieldTypeSupportTest { } @Test - void doesNotSupportsDefinitionCreation_signatureReturnsTrue() { - assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_signatureReturnsFalse() { + // Signature placeholders are now creatable via the editor. + assertFalse(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); } @Test - void doesNotSupportsDefinitionCreation_buttonReturnsTrue() { - assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_buttonReturnsFalse() { + // Push buttons (with actions) are now creatable via the editor. + assertFalse(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); } @Test diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java new file mode 100644 index 0000000000..408380b790 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java @@ -0,0 +1,911 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +/** + * Guards the form editor against silently destroying a field it edits. Assertions run after a + * save/reload cycle because only the serialised document reflects what a viewer sees. + */ +class FormUtilsEditRegressionTest { + + private static PDAcroForm setupForm(PDDocument document) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float x, float y, float w, float h, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null, + null, null); + } + + /** Moves a field to a rect; null width/height leave the size alone. */ + private static FormUtils.ModifyFormFieldDefinition moveTo( + String target, float x, float y, Float w, Float h) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, 0, x, y, w, h, null, null, null, null, null, null, null, + null, null, null); + } + + private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) { + PDField field = acroForm.getField(name); + assertNotNull(field, "field '" + name + "' should exist"); + return field.getWidgets().get(0).getRectangle(); + } + + /** The /AP /N state names on a widget. */ + private static Set normalStateNames(PDAnnotationWidget widget) { + PDAppearanceDictionary appearance = widget.getAppearance(); + assertNotNull(appearance, "widget should have an /AP dictionary"); + PDAppearanceEntry normal = appearance.getNormalAppearance(); + assertNotNull(normal, "widget should have an /AP /N entry"); + assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances"); + return normal.getSubDictionary().keySet().stream() + .map(COSName::getName) + .collect(Collectors.toSet()); + } + + @Test + void movingCheckboxKeepsItFillable() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 200f, 400f, null, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("agree"); + assertTrue(field instanceof PDCheckBox, "'agree' should still be a checkbox"); + assertFalse( + ((PDCheckBox) field).getOnValue().isEmpty(), + "a moved checkbox must keep an on-state, or it can never be ticked again"); + assertTrue( + normalStateNames(field.getWidgets().get(0)).size() >= 2, + "both /AP /N states must survive a move"); + PDRectangle rect = firstWidgetRect(acroForm, "agree"); + assertEquals(200f, rect.getLowerLeftX(), 0.5f); + assertEquals(400f, rect.getLowerLeftY(), 0.5f); + } + } + + @Test + void resizingCheckboxRebuildsAppearanceAtTheNewSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 28f, 28f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox checkBox = (PDCheckBox) acroForm.getField("agree"); + assertFalse( + checkBox.getOnValue().isEmpty(), "a resized checkbox must keep its on-state"); + PDAnnotationWidget widget = checkBox.getWidgets().get(0); + assertTrue(normalStateNames(widget).size() >= 2, "both /AP /N states must be rebuilt"); + PDRectangle bbox = + widget.getAppearance() + .getNormalAppearance() + .getSubDictionary() + .get(COSName.getPDFName(checkBox.getOnValue())) + .getBBox(); + assertEquals(28f, bbox.getWidth(), 0.5f, "the rebuilt /AP must match the new size"); + } + } + + /** applyToggleAppearance parks /AS on Off, so a resize must put the selection back. */ + @Test + void resizingCheckboxKeepsItChecked() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + ((PDCheckBox) form.getField("agree")).check(); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + ((PDCheckBox) acroForm.getField("agree")).isChecked(), + "a resize must not silently untick the box"); + } + } + + /** Only widgets.get(0) used to move, so a radio group lost every option but the first. */ + @Test + void movingRadioGroupMovesEveryOption() throws IOException { + byte[] saved; + float[] before = new float[6]; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of(newField("radio", "choice", 50, 700, 14, 14, List.of("A", "B", "C")))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + List widgets = form.getField("choice").getWidgets(); + assertEquals(3, widgets.size(), "the fixture needs three option widgets"); + for (int i = 0; i < 3; i++) { + before[i * 2] = widgets.get(i).getRectangle().getLowerLeftX(); + before[i * 2 + 1] = widgets.get(i).getRectangle().getLowerLeftY(); + } + FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 670f, null, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("choice"); + assertTrue(field instanceof PDRadioButton, "'choice' should still be a radio group"); + List widgets = field.getWidgets(); + assertEquals(3, widgets.size(), "no option may be left behind"); + float dx = 90f - before[0]; + float dy = 670f - before[1]; + for (int i = 0; i < 3; i++) { + PDRectangle rect = widgets.get(i).getRectangle(); + assertEquals( + before[i * 2] + dx, + rect.getLowerLeftX(), + 0.5f, + "option " + i + " should shift by the same delta"); + assertEquals(before[i * 2 + 1] + dy, rect.getLowerLeftY(), 0.5f); + } + } + } + + /** A signature's /AP is the signature, so it must never be dropped. */ + @Test + void movingSignatureKeepsItsAppearance() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("signature", "sig", 50, 700, 120, 40, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("sig", 60f, 600f, 140f, 50f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getField("sig") instanceof PDSignatureField, + "'sig' should still be a signature"); + assertEquals(60f, firstWidgetRect(acroForm, "sig").getLowerLeftX(), 0.5f); + } + } + + @Test + void invalidFieldNameReason_rejectsPeriodAndAllowsTheRest() { + String reason = FormUtils.invalidFieldNameReason("Customer.Name"); + assertNotNull(reason, "a period must be refused, not silently dropped"); + assertTrue(reason.contains("period"), "the message should name the offending character"); + assertNull(FormUtils.invalidFieldNameReason("Has Space")); + assertNull(FormUtils.invalidFieldNameReason("weird/[]{}")); + assertNull(FormUtils.invalidFieldNameReason(null)); + } + + /** Dropped operations used to log a warning and still report success. */ + @Test + void applyFieldEdits_reportsEveryDroppedOperation() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "present", 50, 700, 200, 20, null))); + + List skipped = new ArrayList<>(); + FormUtils.applyFieldEdits( + document, + List.of(newField("text", "Bad.Name", 50, 600, 100, 20, null)), + List.of(moveTo("ghost", 10f, 10f, null, null)), + List.of("alsoGhost"), + skipped); + + assertEquals(3, skipped.size(), "each dropped operation should be reported"); + assertTrue(skipped.stream().anyMatch(s -> "add".equals(s.operation()))); + assertTrue(skipped.stream().anyMatch(s -> "modify".equals(s.operation()))); + assertTrue(skipped.stream().anyMatch(s -> "delete".equals(s.operation()))); + assertNotNull( + document.getDocumentCatalog().getAcroForm(null).getField("present"), + "the rest of the document must still be applied"); + } + } + + /** A clean batch must not report anything, or the UI would cry wolf on every save. */ + @Test + void applyFieldEdits_reportsNothingWhenEverythingApplies() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + List skipped = new ArrayList<>(); + FormUtils.applyFieldEdits( + document, + List.of(newField("text", "fine", 50, 700, 200, 20, null)), + List.of(), + List.of(), + skipped); + assertTrue(skipped.isEmpty(), "a fully applied batch reports no skips"); + } + } + + /** A drag must not normalise other options to the dragged widget's size. */ + @Test + void movingRadioGroupKeepsEachOptionsOwnSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of(newField("radio", "choice", 50, 700, 20, 20, List.of("A", "B")))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + List widgets = form.getField("choice").getWidgets(); + // Hand-authored groups legitimately have option boxes of differing size. + PDRectangle second = widgets.get(1).getRectangle(); + widgets.get(1) + .setRectangle( + new PDRectangle( + second.getLowerLeftX(), second.getLowerLeftY(), 40f, 40f)); + FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 700f, 20f, 20f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + List widgets = acroForm.getField("choice").getWidgets(); + assertEquals( + 40f, + widgets.get(1).getRectangle().getWidth(), + 0.5f, + "a pure drag must not shrink the other options"); + assertEquals(90f, widgets.get(0).getRectangle().getLowerLeftX(), 0.5f); + } + } + + /** With no /AP and no /Opt the on-state must come from /V, not the invented "Yes". */ + @Test + void resizingCheckboxWithoutAppearanceKeepsItsExportValue() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) form.getField("agree"); + // A NeedAppearances form exported by Word/LibreOffice looks exactly like this. + box.getWidgets().get(0).getCOSObject().removeItem(COSName.AP); + box.getCOSObject().setItem(COSName.V, COSName.getPDFName("On")); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) acroForm.getField("agree"); + assertEquals( + "On", + box.getOnValue(), + "the export value must survive; inventing 'Yes' would orphan /V"); + assertTrue(box.isChecked(), "the box was ticked and must stay ticked"); + } + } + + /** Renaming to the same qualified name is not a rename, so a nested field is not rejected. */ + @Test + void renameProblem_ignoresAnUnchangedQualifiedName() { + assertNull( + FormUtils.renameProblem("Customer.Name", "Customer.Name"), + "a field standing still must not be rejected for its parent's period"); + assertNull(FormUtils.renameProblem("plain", null)); + assertNotNull( + FormUtils.renameProblem("plain", "New.Name"), + "an actual rename introducing a period must still be refused"); + } + + /** A nested field whose name box was left at its qualified name must still be modified. */ + @Test + void modifyingNestedFieldKeepsWorkingWhenNameIsUntouched() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + // Re-parent it so its qualified name legitimately contains a period. + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Name", + null, + null, + 0, + 90f, + 600f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + assertTrue( + skipped.isEmpty(), "an untouched qualified name is not a rename: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("Customer.Name"); + assertNotNull(field, "the nested field must survive the edit"); + assertEquals(90f, field.getWidgets().get(0).getRectangle().getLowerLeftX(), 0.5f); + } + } + + /** Zero clears /MaxLen; null means unchanged, so it could never be removed otherwise. */ + @Test + void maxLengthZeroClearsTheCombSetting() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "code", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null, + null, null, null, null, null, 8, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + assertEquals(8, ((PDTextField) form.getField("code")).getMaxLen()); + + FormUtils.ModifyFormFieldDefinition clear = + new FormUtils.ModifyFormFieldDefinition( + "code", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, 0, null); + FormUtils.modifyFormFields(document, List.of(clear)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertEquals( + -1, + ((PDTextField) acroForm.getField("code")).getMaxLen(), + "/MaxLen should be gone, not merely zero"); + } + } + + /** An unrecognised button action must be reported rather than silently ignored. */ + @Test + void unknownButtonActionIsReported() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "launchTheMissiles"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + + assertEquals(1, skipped.size(), "an unusable action spec should be reported"); + assertTrue(skipped.get(0).reason().contains("launchTheMissiles")); + } + } + + /** Renaming a nested field must not re-parent it to the top level. */ + @Test + void renamingNestedFieldKeepsItUnderItsParent() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition rename = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Phone", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(rename), skipped); + assertTrue( + skipped.isEmpty(), "a leaf rename under the same parent is legal: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull( + acroForm.getField("Customer.Phone"), + "the field should still live under Customer, not at the top level"); + assertNull(acroForm.getField("Customer.Name"), "the old name should be gone"); + } + } + + /** One rejected action on a multi-widget button is one report, not one per widget. */ + @Test + void unknownButtonActionIsReportedOncePerField() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + PDField button = form.getField("go"); + // Give it a second widget, as a button repeated on two pages would have. + PDAnnotationWidget extra = new PDAnnotationWidget(); + extra.setRectangle(new PDRectangle(50, 600, 100, 24)); + extra.getCOSObject().setItem(COSName.PARENT, button.getCOSObject()); + List widgets = new ArrayList<>(button.getWidgets()); + widgets.add(extra); + button.getCOSObject() + .setItem( + COSName.KIDS, + new org.apache.pdfbox.cos.COSArray() { + { + for (PDAnnotationWidget w : widgets) add(w.getCOSObject()); + } + }); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "launchTheMissiles"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + + assertEquals(1, skipped.size(), "one field, one report: " + skipped); + } + } + + /** A clamped page index still creates the field, so it is not a dropped edit. */ + @Test + void clampedPageIsNotReportedAsSkipped() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + List skipped = new ArrayList<>(); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "late", null, "text", 9, 50f, 700f, 100f, 20f, null, null, null, + null, null, null, null, null, null, null)), + skipped); + + assertNotNull( + document.getDocumentCatalog().getAcroForm(null).getField("late"), + "the field is created on the clamped page"); + assertTrue(skipped.isEmpty(), "an applied edit must not appear as skipped: " + skipped); + } + } + + /** Recreation builds a top-level field, so it must refuse rather than re-parent. */ + @Test + void typeChangeOnNestedFieldIsRefusedNotSilentlyReparented() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition retype = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + null, + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(retype), skipped); + + assertEquals(1, skipped.size(), "the refusal must be reported: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull( + acroForm.getField("Customer.Name"), + "the original nested field must be left intact"); + assertNull(acroForm.getField("Name"), "nothing should be re-parented to the top level"); + } + } + + /** The editor emits "uri:" the moment that kind is picked, which must not fail the edit. */ + @Test + void incompleteUrlActionClearsRatherThanFailing() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition pickUri = + new FormUtils.ModifyFormFieldDefinition( + "go", null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, "uri:"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(pickUri), skipped); + + assertTrue( + skipped.isEmpty(), + "choosing a URL action before typing the URL is not an error: " + skipped); + PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go"); + assertNull( + button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A), + "an empty target must leave no action behind"); + } + } + + /** A real URL still writes a real action. */ + @Test + void completeUrlActionIsApplied() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition setUri = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "uri:https://example.com"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(setUri), skipped); + + assertTrue(skipped.isEmpty(), "a complete spec applies cleanly: " + skipped); + PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go"); + assertNotNull( + button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A), + "the action should be written"); + } + } + + /** Builds a parent with the given terminal children already attached. */ + private static PDNonTerminalField nest( + PDDocument document, PDAcroForm form, String parentName, String... childNames) + throws IOException { + List defs = new ArrayList<>(); + for (int i = 0; i < childNames.length; i++) { + defs.add(newField("text", childNames[i], 50, 700 - i * 40, 200, 20, null)); + } + FormUtils.addNewFields(document, defs); + + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName(parentName); + List kids = new ArrayList<>(); + for (String child : childNames) { + PDField field = form.getField(child); + field.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + kids.add(field); + } + parent.setChildren(kids); + form.setFields(List.of(parent)); + return parent; + } + + /** A refused edit must not release the name the field still really has. */ + @Test + void refusedNestedEditDoesNotFreeItsNameForALaterEdit() throws IOException { + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + nest(document, form, "Customer", "Name", "Email"); + + // Edit 1 is refused (type change on a nested field). Edit 2 then asks for the + // name edit 1 still occupies, which must not be handed out. + FormUtils.ModifyFormFieldDefinition refused = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Foo", + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + FormUtils.ModifyFormFieldDefinition rename = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Email", + "Customer.Name", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(refused, rename), skipped); + + List names = new ArrayList<>(); + for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) { + if (f instanceof PDTerminalField) names.add(f.getFullyQualifiedName()); + } + assertEquals( + names.size(), + new java.util.HashSet<>(names).size(), + "two fields must never share a qualified name: " + names); + assertTrue( + names.contains("Customer.Name"), "the refused field keeps its name: " + names); + } + } + + /** A group name occupies the namespace, so a new field must not be able to take it. */ + @Test + void groupNamesParticipateInCollisionChecks() throws IOException { + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + nest(document, form, "Customer", "Name"); + + FormUtils.addNewFields( + document, List.of(newField("text", "Customer", 50, 500, 100, 20, null))); + + List names = new ArrayList<>(); + for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) { + String fqn = f.getFullyQualifiedName(); + if (fqn != null) names.add(fqn); + } + assertEquals( + names.size(), + new java.util.HashSet<>(names).size(), + "the new field must not take the group's name: " + names); + } + } + + /** "Customer." has no leaf, so it must be refused rather than become "Customer.field". */ + @Test + void renameToBareParentPrefixIsRefused() { + assertNotNull( + FormUtils.renameProblem("Customer.Name", "Customer."), + "a name with nothing after the parent prefix is not a rename"); + assertNull(FormUtils.renameProblem("Customer.Name", "Customer.Phone")); + } + + /** A type change must leave the field on its own page, not relocate it to the last one. */ + @Test + void typeChangeKeepsTheFieldOnItsPage() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = new PDAcroForm(document); + for (int i = 0; i < 5; i++) { + document.addPage(new PDPage(PDRectangle.A4)); + } + form.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(form); + + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "onPageTwo", + null, + "text", + 1, + 50f, + 700f, + 200f, + 20f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null))); + + FormUtils.ModifyFormFieldDefinition retype = + new FormUtils.ModifyFormFieldDefinition( + "onPageTwo", + null, + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + FormUtils.modifyFormFields(document, List.of(retype)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("onPageTwo"); + assertNotNull(field, "the retyped field should exist"); + int page = -1; + for (int i = 0; i < reloaded.getNumberOfPages(); i++) { + for (var annot : reloaded.getPage(i).getAnnotations()) { + if (annot.getCOSObject() == field.getWidgets().get(0).getCOSObject()) page = i; + } + } + assertEquals( + 1, page, "a retyped field must stay on its own page, not move to the last"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java new file mode 100644 index 0000000000..2c606d481a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java @@ -0,0 +1,118 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.junit.jupiter.api.Test; + +/** An edit the backend cannot honour must be reported, not logged and reported as success. */ +class FormUtilsEditReportingTest { + + private static FormUtils.NewFormFieldDefinition field(String type, String name) { + return new FormUtils.NewFormFieldDefinition( + name, name, type, 0, 60f, 700f, 120f, 20f, null, null, null, null, null, null, null, + null, null, null); + } + + private static PDDocument blank() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + return document; + } + + @Test + void anUncreatableTypeIsReportedRatherThanSilentlyMadeText() throws IOException { + List skipped = new ArrayList<>(); + try (PDDocument document = blank()) { + FormUtils.addNewFields(document, List.of(field("nonsense", "mystery")), skipped); + PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getFields().isEmpty(), + "an unsupported type must not quietly become a text field"); + } + assertEquals(1, skipped.size(), "the caller must be told: " + skipped); + assertTrue(skipped.get(0).reason().contains("nonsense"), skipped.get(0).reason()); + } + + @Test + void aLyingPageCountIsSurvivable() throws IOException { + // /Count overstates the tree, so getNumberOfPages() passes the guard but getPage throws. + byte[] broken = + ("%PDF-1.4\n" + + "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n" + + "2 0 obj << /Type /Pages /Count 1 /Kids [] >> endobj\n" + + "trailer << /Root 1 0 R >>\n") + .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); + List skipped = new ArrayList<>(); + try (PDDocument document = Loader.loadPDF(broken)) { + // Must not throw; the field is reported as skipped instead. + FormUtils.addNewFields(document, List.of(field("text", "ghost")), skipped); + } catch (IOException loadFailure) { + // A parser that refuses the file outright is an equally acceptable outcome. + return; + } + assertFalse(skipped.isEmpty(), "an unreachable page must be reported, not thrown"); + } + + @Test + void aTwoWidgetCheckboxKeepsItsOnStateWhenMoved() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage(PDRectangle.LETTER)); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "agree", + "agree", + "checkbox", + 0, + 60f, + 700f, + 14f, + 14f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null)), + new ArrayList<>()); + FormUtils.modifyFormFields( + document, + List.of( + new FormUtils.ModifyFormFieldDefinition( + "agree", null, null, null, 0, 200f, 400f, null, null, null, + null, null, null, null, null, null, null, null, null))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saved = out.toByteArray(); + } + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) acroForm.getField("agree"); + assertNotNull(box); + assertFalse(box.getOnValue().isEmpty(), "a moved checkbox must stay tickable"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java new file mode 100644 index 0000000000..a97c06daba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java @@ -0,0 +1,467 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText; +import org.junit.jupiter.api.Test; + +/** + * Assertions run after a save/reload cycle: PDFBox synthesises widgets for fields with no explicit + * {@code /Kids}, so only the serialised document reflects what a viewer sees. + */ +class FormUtilsEditingTest { + + private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) { + PDPage page = new PDPage(pageSize); + document.addPage(page); + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + + private static FormUtils.NewFormFieldDefinition newText( + String name, float x, float y, float w, float h) { + return new FormUtils.NewFormFieldDefinition( + name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null, + null, null); + } + + private static FormUtils.NewFormFieldDefinition newField( + String type, + String name, + float x, + float y, + float w, + float h, + List options, + Integer maxLength, + String buttonAction) { + return new FormUtils.NewFormFieldDefinition( + name, + null, + type, + 0, + x, + y, + w, + h, + null, + null, + options, + null, + null, + null, + null, + null, + maxLength, + buttonAction); + } + + private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) { + PDField field = acroForm.getField(name); + assertNotNull(field, "field '" + name + "' should exist"); + assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget"); + return field.getWidgets().get(0).getRectangle(); + } + + /** + * PDAcroForm.refreshAppearances() never synthesizes /AP for the button family, so without an + * explicit appearance a created checkbox or radio renders blank and resolves to Off. + */ + @Test + void addNewFields_givesToggleFieldsAppearanceStreamsAndKeepsTheirDefault() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField("checkbox", "agree", 50, 600, 20, 20, null, null, null), + newField( + "radio", + "choice", + 50, + 500, + 20, + 20, + List.of("Yes", "No"), + null, + null), + newText("fullname", 50, 400, 200, 24))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm); + + // NeedAppearances=false means viewers trust our streams, so they must exist. + assertFalse(acroForm.getNeedAppearances(), "appearance generation should have run"); + + PDField checkBox = acroForm.getField("agree"); + assertTrue(checkBox instanceof PDCheckBox); + assertEquals( + Set.of("Off", "Yes"), + normalStateNames(checkBox.getWidgets().get(0)), + "checkbox needs an Off and an on-state appearance"); + + PDField radio = acroForm.getField("choice"); + assertTrue(radio instanceof PDRadioButton); + assertEquals(2, radio.getWidgets().size()); + assertEquals(Set.of("Off", "Yes"), normalStateNames(radio.getWidgets().get(0))); + assertEquals(Set.of("Off", "No"), normalStateNames(radio.getWidgets().get(1))); + + // A text field's DA names /Helv; if /DR lacks that alias refreshAppearances throws for + // the whole form and every field above loses its appearance too. + PDField text = acroForm.getField("fullname"); + assertNotNull( + text.getWidgets().get(0).getAppearance().getNormalAppearance(), + "text field should have a generated appearance"); + } + } + + /** The /AP /N state names on a widget. */ + private static Set normalStateNames(PDAnnotationWidget widget) { + PDAppearanceDictionary appearance = widget.getAppearance(); + assertNotNull(appearance, "widget should have an /AP dictionary"); + PDAppearanceEntry normal = appearance.getNormalAppearance(); + assertNotNull(normal, "widget should have an /AP /N entry"); + assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances"); + return normal.getSubDictionary().keySet().stream() + .map(COSName::getName) + .collect(java.util.stream.Collectors.toSet()); + } + + @Test + void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm, "AcroForm should exist after reload"); + assertTrue(acroForm.getField("created") instanceof PDTextField); + PDRectangle rect = firstWidgetRect(acroForm, "created"); + assertNotNull(rect, "created widget should keep its rectangle after reload"); + assertEquals(50f, rect.getLowerLeftX(), 0.5f); + assertEquals(700f, rect.getLowerLeftY(), 0.5f); + assertEquals(200f, rect.getWidth(), 0.5f); + assertEquals(20f, rect.getHeight(), 0.5f); + } + } + + @Test + void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + // Shift the CropBox origin; the frontend sends CropBox-relative coords. + document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700)); + FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "shifted"); + // Absolute = CropBox-relative + CropBox lower-left offset. + assertEquals(15f, rect.getLowerLeftX(), 0.5f); + assertEquals(25f, rect.getLowerLeftY(), 0.5f); + } + } + + @Test + void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.NewFormFieldDefinition def = + new FormUtils.NewFormFieldDefinition( + "opts", + null, + "text", + 0, + 10f, + 10f, + 120f, + 18f, + null, + null, + null, + null, + null, + 18f, + Boolean.TRUE, + Boolean.TRUE, + null, + null); + FormUtils.addNewFields(document, List.of(def)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("opts"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + assertTrue(field instanceof PDTextField); + assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("18"), "default appearance should carry the font size: " + da); + } + } + + @Test + void modifyFormFields_movesAndResizesWidget() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null, + null, null, null, null, null, null, null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "movable"); + assertEquals(100f, rect.getLowerLeftX(), 0.5f); + assertEquals(600f, rect.getLowerLeftY(), 0.5f); + assertEquals(150f, rect.getWidth(), 0.5f); + assertEquals(30f, rect.getHeight(), 0.5f); + } + } + + @Test + void modifyFormFields_setsReadOnlyAndFontSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "editable", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 22f, + Boolean.TRUE, + null, + null, + null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("editable"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("22"), "font size should be reflected in DA: " + da); + } + } + + @Test + void deleteFormFields_removesField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm acroForm = setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20))); + FormUtils.deleteFormFields(document, List.of("temp")); + // After delete the AcroForm may still exist; the field must be gone. + if (acroForm != null) { + assertNull(acroForm.getField("temp")); + } + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue(acroForm == null || acroForm.getField("temp") == null); + } + } + + @Test + void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("choice"); + assertNotNull(field, "radio field should exist"); + assertTrue(field instanceof PDRadioButton, "should be a radio button group"); + assertEquals(2, field.getWidgets().size(), "one widget per option"); + assertTrue(((PDRadioButton) field).getExportValues().contains("Yes")); + assertTrue(((PDRadioButton) field).getExportValues().contains("No")); + } + } + + @Test + void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException { + // A radio group's label is its field name, not its first option, so the viewer label + // matches the name shown in the editor. + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "Choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + FormUtils.FormFieldInfo choice = + FormUtils.extractFormFields(reloaded).stream() + .filter(f -> "Choice".equals(f.name())) + .findFirst() + .orElse(null); + assertNotNull(choice, "radio field should be extracted"); + assertEquals( + "Choice", choice.label(), "field name should win over the first option value"); + } + } + + @Test + void addNewFields_createsCombTextField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDTextField field = (PDTextField) acroForm.getField("ssn"); + assertNotNull(field); + assertEquals(9, field.getMaxLen(), "comb max length should persist"); + assertTrue(field.isComb(), "comb flag should be set"); + } + } + + @Test + void addNewFields_createsSignatureAndButton() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField("signature", "sig", 50, 600, 200, 60, null, null, null), + newField("button", "btn", 50, 500, 120, 24, null, null, "reset"))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getField("sig") instanceof PDSignatureField, + "signature placeholder should exist"); + assertTrue( + acroForm.getField("btn") instanceof PDPushButton, "push button should exist"); + } + } + + @Test + void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20))); + + FormUtils.applyFieldEdits( + document, + List.of(newText("fresh", 50, 600, 200, 20)), + List.of(), + List.of("old")); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm.getField("fresh"), "added field should be present"); + assertNull(acroForm.getField("old"), "deleted field should be gone"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java index dd828327b1..5c13b13908 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java @@ -705,10 +705,20 @@ class FormUtilsGapTest { "newName", "New Label", null, // keep type (text) -> in-place path + null, + null, + null, + null, + null, Boolean.TRUE, null, null, null, + null, + null, + null, + null, + null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -731,7 +741,8 @@ class FormUtilsGapTest { FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "missing", null, null, null, null, null, null, null, null); + "missing", null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -754,7 +765,8 @@ class FormUtilsGapTest { mods.add(null); mods.add( new FormUtils.ModifyFormFieldDefinition( - " ", null, null, null, null, null, null, null, null)); + " ", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null)); FormUtils.modifyFormFields(doc, mods); assertEquals(1, FormUtils.extractFormFields(doc).size()); diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java index f4e013e082..fa3425176a 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java @@ -285,13 +285,13 @@ class FormUtilsMoreTest { } @Test - void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException { + void widgetOutOfBoundsStillReportsItsCoordinates() throws IOException { try (PDDocument doc = new PDDocument()) { SetupDocument setup = createBasicDocument(doc); PDTextField text = new PDTextField(setup.acroForm()); text.setPartialName("offpage"); - // Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates - // returns null, which is still added to the per-field widget list. + // Off the page is legal PDF; dropping it would leave the user unable to drag it + // back. attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20)); List fields = @@ -301,7 +301,8 @@ class FormUtilsMoreTest { fields.get(0).getWidgets(); assertNotNull(widgets); assertEquals(1, widgets.size()); - assertNull(widgets.get(0)); + assertNotNull(widgets.get(0), "a null entry here crashes sorting and the overlay"); + assertEquals(50f, widgets.get(0).getX(), 0.01f); } } @@ -476,8 +477,18 @@ class FormUtilsMoreTest { "combobox", null, null, + null, + null, + null, + null, + null, List.of("One", "Two"), "One", + null, + null, + null, + null, + null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -505,10 +516,20 @@ class FormUtilsMoreTest { null, "listbox", // same type -> in-place path null, + null, + null, + null, + null, + null, Boolean.TRUE, List.of("X", "Y", "Z"), null, - "Choose items"); + "Choose items", + null, + null, + null, + null, + null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -529,7 +550,25 @@ class FormUtilsMoreTest { FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "keep", null, null, "bogusType", null, null, null, null, null); + "keep", + null, + null, + "bogusType", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); FormUtils.modifyFormFields(doc, List.of(mod)); // The field is preserved unchanged because the target type is unsupported. @@ -554,7 +593,8 @@ class FormUtilsMoreTest { // Rename beta -> alpha; should be uniquified to avoid the collision. FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "beta", "alpha", null, null, null, null, null, null, null); + "beta", "alpha", null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -575,7 +615,8 @@ class FormUtilsMoreTest { doc.addPage(new PDPage()); FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "x", null, null, null, null, null, null, null, null); + "x", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); } } diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java new file mode 100644 index 0000000000..d8f5e1deb2 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java @@ -0,0 +1,102 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +/** + * Most real PDFs have no AcroForm at all, so adding the very first field has to build one that + * PDFBox will accept. + */ +class FormUtilsNoAcroFormTest { + + private static final Path PLAIN_PDF = + Path.of("src/test/resources/pdf-ingestion-fixtures/many-tables-test_stress.pdf"); + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float y, List options, String defaultValue) { + // name, label, type, pageIndex, x, y, width, height, required, multiSelect, + // options, defaultValue, tooltip, fontSize, readOnly, multiline, maxLength, buttonAction + return new FormUtils.NewFormFieldDefinition( + name, + name, + type, + 0, + 60f, + y, + 200f, + 20f, + null, + null, + options, + defaultValue, + null, + null, + null, + null, + null, + null); + } + + private static PDDocument loadPlain() throws IOException { + return Loader.loadPDF(Files.readAllBytes(PLAIN_PDF)); + } + + @Test + void plainPdfReallyHasNoAcroForm() throws IOException { + try (PDDocument document = loadPlain()) { + assertNull( + document.getDocumentCatalog().getAcroForm(null), + "fixture must have no AcroForm or this test proves nothing"); + } + } + + @Test + void addsFirstFieldToAPdfWithNoAcroForm() throws IOException { + byte[] saved; + List skipped = new ArrayList<>(); + try (PDDocument document = loadPlain()) { + FormUtils.addNewFields( + document, + List.of( + newField("text", "fullName", 700f, null, "Ada"), + newField("checkbox", "agree", 660f, null, null), + newField("radio", "contact", 600f, List.of("Email", "Post"), null)), + skipped); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saved = out.toByteArray(); + } + + assertTrue(skipped.isEmpty(), "no field should be skipped: " + skipped); + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm, "an AcroForm should have been created"); + assertNotNull(acroForm.getDefaultResources(), "/DR is required for variable text"); + assertTrue( + acroForm.getDefaultAppearance() != null + && !acroForm.getDefaultAppearance().isBlank(), + "/DA is required for variable text"); + PDTextField text = (PDTextField) acroForm.getField("fullName"); + assertNotNull(text, "the text field should exist"); + assertEquals("Ada", text.getValueAsString()); + assertNotNull(acroForm.getField("agree")); + assertNotNull(acroForm.getField("contact")); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java new file mode 100644 index 0000000000..04b317feb8 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java @@ -0,0 +1,175 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +/** + * Option captions belong to the viewer, not the page. Drawing them into the content stream left + * orphan text behind on every move and delete, so these pin the page staying clean. + */ +class FormUtilsRadioCaptionTest { + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float x, float y, float w, float h, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null, + null, null); + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + + private static PDDocument blankWithForm() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + return document; + } + + private static String textOf(byte[] pdf) throws IOException { + try (PDDocument reloaded = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(reloaded); + } + } + + @Test + void radioOptionsAreNotBakedIntoThePage() throws IOException { + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "contact", + 72, + 600, + 12, + 12, + List.of("Email", "Telephone", "Post")))); + saved = save(document); + } + + // The caption is the viewer's job; page content cannot follow a widget that moves. + String text = textOf(saved); + assertFalse(text.contains("Email"), "options must not be page content: " + text); + assertFalse(text.contains("Telephone"), "options must not be page content: " + text); + assertFalse(text.contains("Post"), "options must not be page content: " + text); + } + + @Test + void captionsDoNotReplaceTheWidgetsThemselves() throws IOException { + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of(newField("radio", "size", 72, 600, 12, 12, List.of("S", "M", "L")))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRadioButton radio = (PDRadioButton) acroForm.getField("size"); + assertEquals(3, radio.getWidgets().size(), "one widget per option"); + assertFalse(radio.getExportValues().isEmpty(), "export values must survive"); + } + } + + @Test + void aTextFieldDrawsNoStrayCaption() throws IOException { + // Control: proves the assertions above read the captions and not some unrelated content. + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, List.of(newField("text", "fullName", 72, 600, 200, 18, null))); + saved = save(document); + } + assertTrue(textOf(saved).isBlank(), "a text field should add no page content"); + } + + @Test + void deletingARadioGroupTakesItsCaptionsWithIt() throws IOException { + byte[] withRadio; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "contact", + 72, + 600, + 12, + 12, + List.of("Email", "Telephone", "Post")))); + withRadio = save(document); + } + assertFalse( + textOf(withRadio).contains("Telephone"), + "the group adds no page text to begin with"); + + byte[] afterDelete; + try (PDDocument document = Loader.loadPDF(withRadio)) { + FormUtils.applyFieldEdits(document, List.of(), List.of(), List.of("contact")); + afterDelete = save(document); + } + + String text = textOf(afterDelete); + assertFalse( + text.contains("Telephone"), + "a deleted radio group must not leave its captions on the page: " + text); + } + + @Test + void theDrawnBoxIsTheWholeGroupNotOneOption() { + // A 90pt box used to become a 360pt stack because each option got the full height. + PDRectangle box = new PDRectangle(72f, 500f, 100f, 90f); + var rects = FormUtils.radioOptionRects(box, 3, null, null); + + assertEquals(3, rects.size()); + float top = rects.get(0).getUpperRightY(); + float bottom = rects.get(2).getLowerLeftY(); + assertEquals(90f, top - bottom, 0.01f, "the group must fill exactly the drawn height"); + assertEquals( + box.getUpperRightY(), top, 0.01f, "the first option starts at the box's top edge"); + for (PDRectangle r : rects) { + assertEquals(r.getWidth(), r.getHeight(), 0.01f, "options stay square"); + assertTrue(r.getWidth() <= box.getWidth() + 0.01f, "an option never exceeds the box"); + } + } + + @Test + void explicitSizeAndGapWin() { + PDRectangle box = new PDRectangle(0f, 0f, 100f, 90f); + var rects = FormUtils.radioOptionRects(box, 3, 20f, 14f); + for (PDRectangle r : rects) { + assertEquals(14f, r.getHeight(), 0.01f, "the requested size is used verbatim"); + } + float gap = rects.get(0).getLowerLeftY() - rects.get(1).getUpperRightY(); + assertEquals(20f, gap, 0.01f, "the requested gap is used verbatim"); + } + + @Test + void aSingleOptionStillFitsTheBox() { + var rects = FormUtils.radioOptionRects(new PDRectangle(0f, 0f, 40f, 40f), 1, null, null); + assertEquals(1, rects.size()); + assertTrue(rects.get(0).getHeight() <= 40f, "one option cannot exceed its box"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java new file mode 100644 index 0000000000..8c8d7e14be --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java @@ -0,0 +1,57 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A form with no default resources is ordinary; adding a field to it must still work. */ +class MissingDefaultResourcesTest { + + @Test + @DisplayName("a text field can be added to a form that has no default resources") + void addsToFormWithoutDefaultResources() throws IOException { + // A real upload arrives as bytes, and plenty of forms in the wild carry no /DR at all. + byte[] pdf; + try (PDDocument built = new PDDocument(); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + built.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(built); + // A /DA naming a font with no /DR to resolve it is what PDFBox refuses. + form.setDefaultAppearance("/Helv 0 Tf 0 g"); + form.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.DR); + built.getDocumentCatalog().setAcroForm(form); + built.save(out); + pdf = out.toByteArray(); + } + + try (PDDocument document = org.apache.pdfbox.Loader.loadPDF(pdf)) { + + List skipped = new ArrayList<>(); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "note", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null, + null, null, null, null, null, null, null)), + skipped); + + assertTrue( + skipped.isEmpty(), + "adding a plain text field should not be refused: " + skipped); + assertEquals( + 1, + FormUtils.extractFormFields(document).size(), + "the field should be in the document"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 0e399c1fae..72e5eae9a2 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -206,12 +206,24 @@ class RequestUriUtilsTest { @Test void testIsPublicAuthEndpoint_shareLinkTokenTrailingSlash() { - assertTrue(RequestUriUtils.isPublicAuthEndpoint("/share/abc123/", "")); + assertTrue( + RequestUriUtils.isPublicAuthEndpoint( + "/share/00dcac3a-fc7a-4989-9c4f-97745484d62f/", "")); } @Test void testIsPublicAuthEndpoint_shareLinkWithContextPath() { - assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/share/abc123", "/app")); + assertTrue( + RequestUriUtils.isPublicAuthEndpoint( + "/app/share/00dcac3a-fc7a-4989-9c4f-97745484d62f", "/app")); + } + + @Test + void testIsPublicAuthEndpoint_shareLinkWithInvalidTokenLength() { + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/abc123", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/share/00dcac3a-fc7a-4989-9c4f-97745484d62fa", "")); } @Test @@ -236,4 +248,86 @@ class RequestUriUtilsTest { RequestUriUtils.isPublicAuthEndpoint( "/api/v1/storage/share-links/abc123/metadata", "")); } + + // --- invite-accept SPA bootstrap --- + + private static final String INVITE_TOKEN = "06a20e7e-2e35-4e26-be7d-2dce14f28f12"; + + @Test + void testIsPublicAuthEndpoint_inviteLinkToken() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN, "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteLinkTokenTrailingSlash() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteLinkWithContextPath() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/invite/" + INVITE_TOKEN, "/app")); + } + + @Test + void testIsPublicAuthEndpoint_inviteRootNotPublic() { + // Avoid matching bare "/invite" or "/invite/" - must have a token segment + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteNestedPathNotPublic() { + // Guard against future additions like /invite//foo becoming accidentally public + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/foo", "")); + } + + @Test + void testIsPublicAuthEndpoint_invitePrefixDoesNotOvermatch() { + // "/inviteX" must not match the invite pattern + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/inviteX", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteNonUuidTokenNotPublic() { + // Only exactly-shaped 36-char lowercase UUID tokens are treated as invite links + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc123", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteUppercaseUuidNotPublic() { + // Tokens are generated lowercase by UUID.randomUUID().toString() + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06A20E7E-2E35-4E26-BE7D-2DCE14F28F12", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteWrongLengthNotPublic() { + // 35-char and 37-char UUID-like tokens are not valid UUIDs + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f122", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteWrongGroupingNotPublic() { + // Groups of 8-4-4-4-4 must not be shifted around (e.g. 4-4-4-4-8) + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a2-0e7e-2e35-4e26-be7d2dce14f28f12", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteTokenInvalidCharsNotPublic() { + // Hex-only; anything outside [0-9a-f] or the UUID hyphens is rejected + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc$123", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc..123", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc%2F123", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1g", "")); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 62cb1539fc..871ec49fa8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -216,7 +216,8 @@ public class WebMvcConfig implements ContainerResponseFilter { headers.putSingle( "Access-Control-Expose-Headers", "WWW-Authenticate, X-Total-Count, X-Page-Number, X-Page-Size, Content-Disposition," - + " Content-Type"); + + " Content-Type, X-Stirling-Skipped-Field-Edits," + + " X-Stirling-Skipped-Field-Edits-Total"); headers.putSingle("Access-Control-Allow-Credentials", "true"); headers.putSingle("Access-Control-Max-Age", "3600"); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java index 20b395230e..a74ab5c72f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java @@ -2,10 +2,19 @@ package stirling.software.SPDF.controller.api.form; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.poi.ss.usermodel.*; @@ -24,6 +33,7 @@ import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -35,6 +45,7 @@ import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; +import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @@ -59,10 +70,94 @@ import tools.jackson.databind.ObjectMapper; @RequiredArgsConstructor public class FormFillController { + /** Carries the edits a request asked for but the document could not take, as base64 JSON. */ + public static final String SKIPPED_EDITS_HEADER = "X-Stirling-Skipped-Field-Edits"; + + /** How many were skipped in total, which may exceed the number listed in the header above. */ + public static final String SKIPPED_EDITS_TOTAL_HEADER = "X-Stirling-Skipped-Field-Edits-Total"; + + /** Keeps the header well inside the container's response-header budget. */ + private static final int MAX_REPORTED_SKIPS = 20; + + /** Bytes of encoded header value, well under the container's limit for the whole header set. */ + private static final int MAX_SKIP_HEADER_BYTES = 4096; + + private static final int MAX_SKIP_FIELD_CHARS = 120; + + /** Entry names inside the {@code ?includeFields=true} bundle. */ + private static final String FIELDS_ENTRY = "fields.json"; + + private static final String DOCUMENT_ENTRY = "document.pdf"; + private final CustomPDFDocumentFactory pdfDocumentFactory; private final ObjectMapper objectMapper; private final TempFileManager tempFileManager; + /** + * Rejects field names PDFBox cannot store before the document is touched, so the caller gets a + * 400 naming the offending character instead of a 200 with the field quietly missing. + */ + private static void requireUsableFieldNames( + List adds, + List modifies) { + Stream problems = + Stream.concat( + adds.stream() + .map(FormUtils.NewFormFieldDefinition::name) + .map(FormUtils::invalidFieldNameReason), + // A rename to the same name is not a rename, so a nested field whose + // qualified name already contains a period is left alone. + modifies.stream() + .map(m -> FormUtils.renameProblem(m.targetName(), m.name()))); + problems.filter(Objects::nonNull) + .findFirst() + .ifPresent( + reason -> { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", "{0}", reason); + }); + } + + /** + * The body is the updated PDF, so dropped edits travel as a base64 JSON header; + * percent-encoding would turn every space into a plus sign. + */ + private Response withSkippedEdits( + Response response, List skipped) { + if (skipped.isEmpty()) { + return response; + } + // A count cap alone is not enough: one very long field name can still overflow the + // header budget and turn the response into an error page, losing the edited PDF. + List reported = new ArrayList<>(); + String encoded = ""; + for (FormUtils.SkippedFieldEdit edit : skipped) { + if (reported.size() >= MAX_REPORTED_SKIPS) { + break; + } + reported.add( + new FormUtils.SkippedFieldEdit( + edit.operation(), + FormUtils.abbreviate(edit.target(), MAX_SKIP_FIELD_CHARS), + FormUtils.abbreviate(edit.reason(), MAX_SKIP_FIELD_CHARS))); + String candidate = + Base64.getEncoder() + .encodeToString( + objectMapper + .writeValueAsString(reported) + .getBytes(StandardCharsets.UTF_8)); + if (candidate.length() > MAX_SKIP_HEADER_BYTES) { + reported.removeLast(); + break; + } + encoded = candidate; + } + return Response.fromResponse(response) + .header(SKIPPED_EDITS_TOTAL_HEADER, String.valueOf(skipped.size())) + .header(SKIPPED_EDITS_HEADER, encoded) + .build(); + } + private Response saveDocument(PDDocument document, String baseName) throws IOException { return WebResponseUtils.pdfDocToWebResponse(document, baseName + ".pdf", tempFileManager); } @@ -246,6 +341,79 @@ public class FormFillController { } } + @POST + @Path("/add-fields") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation( + summary = "Add new form fields", + description = + "Creates new form fields in the provided PDF and returns the updated file") + public Response addFields( + @RestForm("file") FileUpload fileUpload, @RestForm("fields") FileUpload fieldsUpload) + throws IOException { + + MultipartFile file = FileUploadMultipartFile.of(fileUpload); + String rawFields = decodePart(fieldsUpload); + List definitions = + FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields); + if (definitions.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", + "{0} must contain at least one definition", + "fields payload"); + } + + requireUsableFieldNames(definitions, List.of()); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.addNewFields(document, definitions, skipped)), + skipped); + } + + // includeFields stays a query parameter, not a form part: the client sends it in the URL. + @POST + @Path("/edit-fields") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation( + summary = "Apply a batch of form field edits", + description = + "Adds, modifies, and deletes form fields in a single request (one document" + + " load/save) and returns the updated file") + public Response editFields( + @RestForm("file") FileUpload fileUpload, + @RestForm("edits") FileUpload editsUpload, + @QueryParam("includeFields") @DefaultValue("false") boolean includeFields) + throws IOException { + + MultipartFile file = FileUploadMultipartFile.of(fileUpload); + String rawEdits = decodePart(editsUpload); + FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits); + if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", "{0} must contain at least one edit", "edits payload"); + } + requireUsableFieldNames(batch.add(), batch.modify()); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + includeFields, + document -> + FormUtils.applyFieldEdits( + document, + batch.add(), + batch.modify(), + batch.delete(), + skipped)), + skipped); + } + @POST @Path("/modify-fields") @Consumes(MediaType.MULTIPART_FORM_DATA) @@ -268,8 +436,15 @@ public class FormFillController { "updates payload"); } - return processSingleFile( - file, "updated", document -> FormUtils.modifyFormFields(document, modifications)); + requireUsableFieldNames(List.of(), modifications); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.modifyFormFields(document, modifications, skipped)), + skipped); } @POST @@ -290,8 +465,13 @@ public class FormFillController { "error.dataRequired", "{0} must contain at least one value", "names payload"); } - return processSingleFile( - file, "updated", document -> FormUtils.deleteFormFields(document, names)); + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.deleteFormFields(document, names, skipped)), + skipped); } @POST @@ -320,13 +500,81 @@ public class FormFillController { private Response processSingleFile( MultipartFile file, String suffix, DocumentProcessor processor) throws IOException { + return processSingleFile(file, suffix, false, processor); + } + + private Response processSingleFile( + MultipartFile file, String suffix, boolean includeFields, DocumentProcessor processor) + throws IOException { requirePdf(file); String baseName = buildBaseName(file, suffix); try (PDDocument document = pdfDocumentFactory.load(file)) { FormUtils.repairMissingWidgetPageReferences(document); processor.accept(document); - return saveDocument(document, baseName); + return includeFields + ? saveDocumentWithFields(document, baseName) + : saveDocument(document, baseName); + } + } + + /** + * Answers "what fields does the saved file have?" from the document still open here, so the + * caller does not have to upload the result back to ask. + */ + private Response saveDocumentWithFields(PDDocument document, String baseName) + throws IOException { + TempFile zip = null; + boolean zipTransferred = false; + try (TempFile pdf = tempFileManager.createManagedTempFile(".pdf")) { + document.save(pdf.getPath().toFile()); + // Read the fields after the save so they describe the bytes actually being returned. + byte[] fields = + objectMapper.writeValueAsBytes( + FormUtils.extractFormFieldsWithCoordinates(document)); + zip = tempFileManager.createManagedTempFile(".zip"); + writeFieldBundle(zip.getPath(), pdf.getPath(), fields); + Response response = WebResponseUtils.zipFileToWebResponse(zip, baseName + ".zip"); + zipTransferred = true; + return response; + } finally { + if (zip != null && !zipTransferred) { + zip.close(); + } + } + } + + /** + * Deflates the JSON because it is text, but stores the PDF: its streams are already compressed, + * so deflating costs ~25ms per MB to save a few percent. + */ + private static void writeFieldBundle( + java.nio.file.Path zipPath, java.nio.file.Path pdfPath, byte[] fields) + throws IOException { + long pdfSize = Files.size(pdfPath); + CRC32 crc = new CRC32(); + try (InputStream in = Files.newInputStream(pdfPath)) { + byte[] buffer = new byte[8192]; + for (int read; (read = in.read(buffer)) != -1; ) { + crc.update(buffer, 0, read); + } + } + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) { + ZipEntry fieldsEntry = new ZipEntry(FIELDS_ENTRY); + fieldsEntry.setMethod(ZipEntry.DEFLATED); + zip.putNextEntry(fieldsEntry); + zip.write(fields); + zip.closeEntry(); + + ZipEntry documentEntry = new ZipEntry(DOCUMENT_ENTRY); + documentEntry.setMethod(ZipEntry.STORED); + documentEntry.setSize(pdfSize); + documentEntry.setCompressedSize(pdfSize); + documentEntry.setCrc(crc.getValue()); + zip.putNextEntry(documentEntry); + Files.copy(pdfPath, zip); + zip.closeEntry(); + zip.finish(); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index 6f82c7546e..f48f419a6d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -28,6 +28,8 @@ final class FormPayloadParser { private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private static final TypeReference> MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> NEW_FIELD_LIST_TYPE = + new TypeReference<>() {}; private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; private FormPayloadParser() {} @@ -94,6 +96,43 @@ final class FormPayloadParser { return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); } + static List parseNewFieldDefinitions( + ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE); + } + + /** + * Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each + * section is optional. The delete section accepts the same shapes as {@link #parseNameList}. + */ + static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of()); + } + final JsonNode root = objectMapper.readTree(json); + List adds = List.of(); + List modifies = List.of(); + List deletes = List.of(); + if (root != null && root.isObject()) { + final JsonNode addNode = root.get("add"); + if (addNode != null && addNode.isArray()) { + adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE); + } + final JsonNode modifyNode = root.get("modify"); + if (modifyNode != null && modifyNode.isArray()) { + modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE); + } + final JsonNode deleteNode = root.get("delete"); + if (deleteNode != null && !deleteNode.isNull()) { + deletes = parseNameList(objectMapper, deleteNode.toString()); + } + } + return new FormUtils.FieldEditBatch(adds, modifies, deletes); + } + static List parseNameList(ObjectMapper objectMapper, String json) { if (json == null || json.isBlank()) { return List.of(); diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index fbdce0a158..a0dae640e0 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -17,7 +17,7 @@ { "moduleName": "ch.qos.logback:logback-classic", "moduleUrl": "http://www.qos.ch", - "moduleVersion": "1.6.1", + "moduleVersion": "1.6.3", "moduleLicense": "LGPL-2.1-only", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, @@ -31,7 +31,7 @@ { "moduleName": "ch.qos.logback:logback-core", "moduleUrl": "http://www.qos.ch", - "moduleVersion": "1.6.1", + "moduleVersion": "1.6.3", "moduleLicense": "LGPL-2.1-only", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, @@ -527,27 +527,45 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.common:common-image", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.common:common-io", "moduleVersion": "3.13.1", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.common:common-io", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.common:common-lang", "moduleVersion": "3.13.1", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.common:common-lang", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-batik", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, { "moduleName": "com.twelvemonkeys.imageio:imageio-bmp", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, @@ -557,9 +575,15 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-core", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-jpeg", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, @@ -569,9 +593,15 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-psd", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, @@ -581,12 +611,24 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-webp", "moduleVersion": "3.13.1", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-webp", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.vladsch.flexmark:flexmark", "moduleVersion": "0.64.8", @@ -1022,21 +1064,14 @@ { "moduleName": "io.swagger.core.v3:swagger-annotations-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations", - "moduleVersion": "2.2.46", + "moduleVersion": "2.2.47", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "io.swagger.core.v3:swagger-annotations-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations", - "moduleVersion": "2.2.47", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" - }, - { - "moduleName": "io.swagger.core.v3:swagger-core-jakarta", - "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core", - "moduleVersion": "2.2.46", + "moduleVersion": "2.2.53", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1048,9 +1083,9 @@ "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { - "moduleName": "io.swagger.core.v3:swagger-models-jakarta", - "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models", - "moduleVersion": "2.2.46", + "moduleName": "io.swagger.core.v3:swagger-core-jakarta", + "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core", + "moduleVersion": "2.2.53", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1061,6 +1096,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "io.swagger.core.v3:swagger-models-jakarta", + "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models", + "moduleVersion": "2.2.53", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "jakarta.activation:jakarta.activation-api", "moduleUrl": "https://www.eclipse.org", @@ -2262,7 +2304,7 @@ }, { "moduleName": "org.simplejavamail:core-module", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -2275,13 +2317,13 @@ }, { "moduleName": "org.simplejavamail:outlook-module", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.simplejavamail:simple-java-mail", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -2301,10 +2343,10 @@ }, { "moduleName": "org.snakeyaml:snakeyaml-engine", - "moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml-engine", - "moduleVersion": "3.0.1", + "moduleUrl": "https://codeberg.org/snakeyaml/snakeyaml-engine", + "moduleVersion": "3.1.1", "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.springdoc:springdoc-openapi-starter-common", diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java new file mode 100644 index 0000000000..8082949c8e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java @@ -0,0 +1,374 @@ +package stirling.software.SPDF.controller.api.form; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.model.FormFieldWithCoordinates; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.FormUtils; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Drives ?includeFields=true across a spread of real form shapes, checking the bundled list stays + * interchangeable with the follow-up request it exists to remove. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("edit-fields field bundle") +class FormFieldBundleTest { + + /** Set to a directory to dump the produced archives for the frontend reader's fixtures. */ + private static final String FIXTURE_DIR = System.getProperty("bundle.fixtures"); + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + @InjectMocks private FormFillController controller; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() throws Exception { + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + invocation -> { + File file = + Files.createTempFile( + "bundle", invocation.getArgument(0)) + .toFile(); + TempFile temp = mock(TempFile.class); + lenient().when(temp.getFile()).thenReturn(file); + lenient().when(temp.getPath()).thenReturn(file.toPath()); + return temp; + }); + objectMapper = JsonMapper.builder().build(); + var field = FormFillController.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(controller, objectMapper); + } + + // -- document shapes ---------------------------------------------- + + private record Style( + String name, int pages, int rotation, List fields) {} + + private static FormUtils.NewFormFieldDefinition field( + String name, String type, int page, float y, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, page, 50f, y, 200f, 20f, null, null, options, null, null, null, + null, null, null, null); + } + + static List
{banner}
{children}
diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 7fd2eda133..40e17dc502 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -978,7 +978,9 @@ function FileCard({ )}
{fileSize} - · + {fileDate}
diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index e87b348780..36ffb9c4dc 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -33,6 +33,10 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import RefreshIcon from "@mui/icons-material/Refresh"; +import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulkMenu"; +import { FilesToolbarCount } from "@app/components/filesPage/FilesToolbarCount"; +import { FilesToolbarFilterMenu } from "@app/components/filesPage/FilesToolbarFilterMenu"; +import { FilesToolbarSortMenu } from "@app/components/filesPage/FilesToolbarSortMenu"; import { stripBasePath } from "@app/constants/app"; import { useAuth } from "@app/auth/UseSession"; @@ -893,6 +897,9 @@ export default function FileManagerView() { () => Array.from(selectedFileIds), [selectedFileIds], ); + // A phone with files selected shows a contextual selection bar instead of the + // full toolbar - five bulk buttons plus filters cannot fit the width. + const mobileSelection = isMobile && selectedFiles.length > 0; // Local-only subset of selection; drives Save-to-server visibility. const localOnlySelectedStubs = useMemo( @@ -1210,22 +1217,12 @@ export default function FileManagerView() { })()}
- - {loading - ? t("filesPage.loading", "Loading…") - : t("filesPage.summary", "{{count}} items", { - count: totalCount, - })} - {selectedFiles.length > 0 && ( - - {" "} - ·{" "} - {t("filesPage.selectedCount", "{{count}} selected", { - count: selectedFiles.length, - })} - - )} - + {(() => { // Select all / Clear toggle over visible files. if (visibleFiles.length === 0) return null; @@ -1265,289 +1262,382 @@ export default function FileManagerView() { ); })()}
- {selectedFiles.length > 0 && - (() => { - // Bulk-action labels; CSS collapses to icon-only below 900px. - const addLabel = + {mobileSelection ? ( + handleAddToWorkspace(selectedFiles)} + onSaveToServer={ + localOnlySelectedStubs.length > 0 + ? () => setSaveToServerTarget(localOnlySelectedStubs) + : undefined + } + saveToServerDisabledReason={ + saveToServerDisabledReason ?? undefined + } + onShowDetails={ selectedFiles.length === 1 - ? t("filesPage.addToWorkspace", "Add to workspace") - : t( - "filesPage.addToWorkspaceCount", - "Add {{count}} to workspace", - { count: selectedFiles.length }, - ); - const moveLabel = t("filesPage.moveTo", "Move to…"); - const removeLabel = t("filesPage.remove", "Remove"); - return ( - // wrap="nowrap" keeps the row single-line. - - - - - {/* Save to server; shown whenever local-only files are + ? () => setMobileDetailsOpen(true) + : undefined + } + onMove={() => promptMoveFiles(selectedFiles)} + onRemove={() => handleRemoveFiles(selectedFiles)} + /> + ) : ( + <> + {selectedFiles.length > 0 && + (() => { + // Bulk-action labels; CSS collapses to icon-only below 900px. + const addLabel = + selectedFiles.length === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t( + "filesPage.addToWorkspaceCount", + "Add {{count}} to workspace", + { count: selectedFiles.length }, + ); + const moveLabel = t("filesPage.moveTo", "Move to…"); + const removeLabel = t("filesPage.remove", "Remove"); + return ( + // wrap="nowrap" keeps the row single-line. + + + + + {/* Save to server; shown whenever local-only files are selected. When storage is off it stays visible but disabled, tooltip pointing at the admin. */} - {localOnlySelectedStubs.length > 0 && ( - - + + )} + {/* Show details button on compact viewports. */} + {selectedFiles.length === 1 && + isCompactDetailsViewport && ( + + + )} - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {/* Show details button on compact viewports. */} - {selectedFiles.length === 1 && - isCompactDetailsViewport && ( - + + + + + + clearSelection()} + aria-label={t( + "filesPage.clearSelection", + "Clear selection", + )} + > + × + + + + ); + })()} + {selectedFiles.length > 0 && ( +
diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index aa9d8248aa..6d4070edb6 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -215,6 +215,17 @@ .files-page-toolbar-actions .mantine-Button-label { display: none; } +/* Exception: the bulk-actions trigger IS its label. Collapsed to an icon it + would read as a bare chevron with nothing to say what it opens. */ +.files-page-toolbar-actions + .files-page-toolbar-bulk-trigger + .mantine-Button-label { + display: inline; +} +.files-page-toolbar-actions .files-page-toolbar-bulk-trigger { + padding-left: 0.75rem; + padding-right: 0.5rem; +} /* Pin the view toggle: never let it clip off the right. flex-shrink:0 keeps its width fixed; the rest of the row shrinks around it. */ .files-page-toolbar-actions .mantine-SegmentedControl-root { @@ -476,6 +487,23 @@ gap: 0.4rem; } +/* Narrow cards: let the values wrap as whole units onto their own lines rather + than breaking mid-value ("239.26 / KB") around a stranded separator. */ +@media (max-width: 64rem) { + .files-page-card-meta { + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.4rem; + row-gap: 0.05rem; + } + .files-page-card-meta > span { + white-space: nowrap; + } + .files-page-card-meta-sep { + display: none; + } +} + /* Parent-folder breadcrumb shown on cards/rows during recursive search so the user can tell which folder each hit lives in without navigating. */ .files-page-card-path { @@ -1295,30 +1323,38 @@ sits next to the Upload button without breaking the action row. */ display: none; } -@media (max-width: 900px) { +@media (max-width: 1024px) { .files-page-toolbar { /* nowrap so "7 items" + "Select all" sit on the same row as the filter dropdowns and view-toggle instead of stacking on three - separate lines. Per-child min-width:0 lets them shrink as needed. - Used to only kick in at ≤640px which left a broken zone where - both side panels were hidden but the toolbar was still wrapping - to multiple rows. */ + separate lines. Runs to the app's mobile breakpoint: capping it at + 900px left 901-1024px wrapping to two rows, which is the band the + mobile layout actually renders in. + + Scrolls rather than clips. With a selection active the bulk-action + strip cannot fit any phone width, and `overflow-x: hidden` put those + buttons permanently out of reach behind the edge. */ flex-wrap: nowrap; gap: 0.35rem; padding: 0.35rem 0.5rem; min-height: auto; - overflow-x: hidden; + overflow-x: auto; + scrollbar-width: none; + } + .files-page-toolbar::-webkit-scrollbar { + display: none; } .files-page-toolbar-info { - /* Was `flex-basis: 100%` which forced a row break. Let it share - the row, shrink hard if needed, and ellipsize so the count line - collapses gracefully (was overlapping the bulk-action buttons - at ~400px because no truncation rule existed). */ - flex: 0 1 auto; + /* The toolbar's only status text. Pinned, because against nowrap + siblings it lost every shrink round and rendered as "3 i". */ + flex-shrink: 0; min-width: 0; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + } + /* Filter and sort collapse to icon triggers here (see FilesToolbar*Menu); + they are the whole control, so they never shrink. */ + .files-page-toolbar-icon-btn { + flex-shrink: 0; } .files-page-toolbar-actions { flex-wrap: nowrap; @@ -1350,41 +1386,48 @@ navigation, so the in-header Home/Apps/Close trio is duplicated and the first to go. Same for "Upload" - the user can use the centre drop overlay. */ -@media (max-width: 640px) { - /* Drop the 3-column grid on phones; flex-wrap lets the search slip onto - * its own row when chrome is too cramped to share. */ +/* ── Mobile + tablet chrome (≤1024px = useIsMobile) ────────────────── + The desktop header is a 3-column grid whose middle track can grow to + 40rem. Below ~1024px that track eats the row: the breadcrumb column + collapsed to ~36px (wrapping "All files" to two lines) and the action + column overflowed, pushing Upload off the right edge. One flex row + instead - breadcrumb and actions keep their intrinsic width and the + search takes whatever is left. Ends at the app's mobile breakpoint so + it matches the layout HomePage is already rendering. */ +@media (max-width: 1024px) { .files-page-header { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; + align-items: center; gap: 0.4rem; - padding: 0 0.4rem; + padding: 0.25rem 0.4rem; overflow-x: hidden; } - .files-page-header [data-mobile-hide="true"] { - display: none !important; + .files-page-header-search { + flex: 1 1 auto; + min-width: 0; + justify-content: flex-start; } - .files-page-header [data-desktop-hide="true"] { - display: inline-flex !important; - } - /* Mobile-hide for sub-toolbar create buttons. */ - .files-page-toolbar [data-mobile-hide="true"] { - display: none !important; + /* Undo the fixed 24rem basis so the pill tracks the row's spare width. */ + .files-page-header-search .super-search { + flex: 1 1 auto; + width: 100%; + max-width: none; } .files-page-header-actions { + flex: 0 0 auto; margin-left: auto; - gap: 0.3rem; + gap: 0.25rem; flex-wrap: nowrap; } .files-page-breadcrumbs { + flex: 0 1 auto; font-size: 0.85rem; flex-wrap: nowrap; overflow-x: auto; min-width: 0; } - /* Upload becomes an icon-only square button on mobile so the action - row stops getting clipped. Scoped to `.files-page-header-actions` - so the Back button at the header level keeps its visible "Back" - label (Back has no other on-screen indicator that it's about leaving). */ + /* Icon-only actions: the labels are what pushed Upload past the edge. */ .files-page-header-actions .mantine-Button-root { padding-left: 0.55rem; padding-right: 0.55rem; @@ -1395,6 +1438,9 @@ .files-page-header-actions .mantine-Button-label { display: none; } +} + +@media (max-width: 640px) { /* Grid: single column on very narrow phones; two columns from ~440px */ .files-page-grid { grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx new file mode 100644 index 0000000000..3e344ac7b0 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx @@ -0,0 +1,104 @@ +import { Menu } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; + +import { Button } from "@app/ui/Button"; + +interface FilesToolbarBulkMenuProps { + selectedCount: number; + onAddToWorkspace: () => void; + /** Local-only files in the selection; omit when there are none to upload. */ + onSaveToServer?: () => void; + /** Set when storage is off - the item stays listed but disabled. */ + saveToServerDisabledReason?: string; + onShowDetails?: () => void; + onMove: () => void; + onRemove: () => void; +} + +/** + * Bulk actions behind one trigger. The full strip is five buttons wide, which + * no phone can hold alongside the count and the clear control, so rather than + * letting the row scroll them off the edge they collapse into a menu where + * every action keeps its label. + */ +export function FilesToolbarBulkMenu({ + selectedCount, + onAddToWorkspace, + onSaveToServer, + saveToServerDisabledReason, + onShowDetails, + onMove, + onRemove, +}: FilesToolbarBulkMenuProps) { + const { t } = useTranslation(); + + const addLabel = + selectedCount === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t("filesPage.addToWorkspaceCount", "Add {{count}} to workspace", { + count: selectedCount, + }); + + return ( + + + + + + } + onClick={onAddToWorkspace} + > + {addLabel} + + {onSaveToServer && ( + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={onSaveToServer} + > + {t("filesPage.saveToServer", "Save to server")} + + )} + {onShowDetails && ( + } + onClick={onShowDetails} + > + {t("filesPage.showDetails", "Show details")} + + )} + } + onClick={onMove} + > + {t("filesPage.moveTo", "Move to…")} + + + } + onClick={onRemove} + > + {t("filesPage.remove", "Remove")} + + + + ); +} + +export default FilesToolbarBulkMenu; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx new file mode 100644 index 0000000000..d808638d63 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; + +interface FilesToolbarCountProps { + loading: boolean; + totalCount: number; + selectedCount: number; + /** + * Selection-bar mode: report only the selection. A phone spends the room on + * the actions rather than on "3 items · 3 selected". + */ + selectionOnly: boolean; +} + +/** Status text at the head of the files toolbar. */ +export function FilesToolbarCount({ + loading, + totalCount, + selectedCount, + selectionOnly, +}: FilesToolbarCountProps) { + const { t } = useTranslation(); + + const selected = t("filesPage.selectedCount", "{{count}} selected", { + count: selectedCount, + }); + + if (selectionOnly) { + return {selected}; + } + + return ( + + {loading + ? t("filesPage.loading", "Loading…") + : t("filesPage.summary", "{{count}} items", { count: totalCount })} + {selectedCount > 0 && · {selected}} + + ); +} + +export default FilesToolbarCount; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx new file mode 100644 index 0000000000..81d84daf67 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx @@ -0,0 +1,149 @@ +import { MultiSelect, Popover, Select, Stack, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "@mui/icons-material/Close"; +import SearchIcon from "@mui/icons-material/Search"; +import TuneIcon from "@mui/icons-material/Tune"; + +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button } from "@app/ui/Button"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import type { FilesPageOriginFilter } from "@app/contexts/FilesPageContext"; + +interface FilesToolbarFilterMenuProps { + originFilter: FilesPageOriginFilter; + onOriginChange: (value: FilesPageOriginFilter) => void; + availableTypes: string[]; + typeFilter: string[]; + onTypeChange: (value: string[]) => void; + search: string; + onSearchChange: (value: string) => void; +} + +/** + * Source, type and name filters collapsed behind one icon. Side by side these + * three need ~480px, so on narrow viewports they were each truncated to + * unreadable stubs ("All sour"). In the popover they get their full width back, + * and a dot on the trigger keeps an active filter discoverable while hidden. + */ +export function FilesToolbarFilterMenu({ + originFilter, + onOriginChange, + availableTypes, + typeFilter, + onTypeChange, + search, + onSearchChange, +}: FilesToolbarFilterMenuProps) { + const { t } = useTranslation(); + + const activeCount = + (originFilter !== "all" ? 1 : 0) + + (typeFilter.length > 0 ? 1 : 0) + + (search.trim() !== "" ? 1 : 0); + const label = t("filesPage.filters.label", "Filters"); + + const clearAll = () => { + onOriginChange("all"); + onTypeChange([]); + onSearchChange(""); + }; + + return ( + + +
+ 0 + ? t( + "filesPage.filters.activeCount", + "{{count}} filters active", + { + count: activeCount, + }, + ) + : label + } + position="bottom" + > + 0 ? "primary" : "tertiary"} + size="sm" + aria-label={label} + className="files-page-toolbar-icon-btn" + > + + + +
+
+ + + onSearchChange(e.currentTarget.value)} + placeholder={t("filesPage.search.placeholder", "Filter files…")} + leftSection={} + rightSection={ + search ? ( + onSearchChange("")} + aria-label={t("filesPage.search.clear", "Clear filter")} + > + + + ) : null + } + aria-label={t("filesPage.search.label", "Filter files by name")} + /> + + } + value={canRetype ? value.type : null} + data={TYPE_CHANGE_OPTIONS.map((tp) => ({ + value: tp, + label: TYPE_LABEL[tp], + }))} + disabled={!canRetype} + placeholder={canRetype ? undefined : TYPE_LABEL[value.type]} + onChange={(v) => v && onChange({ type: v })} + comboboxProps={{ withinPortal: true }} + /> + )} + + {hasOptions && ( + + + + + {(value.options ?? []).length === 0 && ( + + {t("formFill.editor.optionsEmpty", "Add at least one option.")} + + )} + {(value.options ?? []).map((opt, i) => ( + + updateOption(i, e.currentTarget.value)} + /> + removeOption(i)} + > + + + + ))} + + + {value.type === "radio" && ( + // The group fits the drawn box by default; these are for tightening it by hand. + + + } + value={value.optionSize ?? ""} + min={1} + max={144} + data-testid="form-option-size" + onChange={(v) => + onChange({ + optionSize: typeof v === "number" ? v : undefined, + }) + } + /> + + } + value={value.optionGap ?? ""} + min={0} + max={144} + data-testid="form-option-gap" + onChange={(v) => + onChange({ optionGap: typeof v === "number" ? v : undefined }) + } + /> + + )} + + )} + + {isFillable && ( + + } + value={value.defaultValue ?? ""} + onChange={(e) => onChange({ defaultValue: e.currentTarget.value })} + /> + )} + + + } + value={value.tooltip ?? ""} + onChange={(e) => onChange({ tooltip: e.currentTarget.value })} + /> + + {isButton && ( + <> + ({ - value: String(connection.id), - label: connection.name, - }))} - onChange={(selected) => onChange(selected ?? "")} - /> + {showSelect && ( + ({ + value: o.value, + label: t(o.labelKey), + }))} + onChange={(value) => setOption(field.key, value ?? "")} + /> + ); + } + + function renderInputControl(field: SourceFieldDef) { + return ( + setOption(field.key, e.target.value)} + /> + ); + } + + function renderControl(field: SourceFieldDef) { + switch (field.control) { + case "s3Connection": + return renderS3ConnectionControl(field); + case "connection": + return renderConnectionControl(field); + case "select": + return renderSelectControl(field); + default: + return renderInputControl(field); + } + } + + function renderField(field: SourceFieldDef) { + return ( + + {renderControl(field)} + + ); + } + + // A field can gate itself on another's current value (e.g. change detection + // only applies in consume mode), so a knob that does nothing never shows. + function fieldVisible(field: SourceFieldDef): boolean { + const cond = field.visibleWhen; + return !cond || (options[cond.key] ?? "") === cond.equals; + } + + const visibleFields = type.fields.filter(fieldVisible); + const primaryFields = visibleFields.filter((field) => !field.advanced); + const advancedFields = visibleFields.filter((field) => field.advanced); + const back = stageBack(); + + return ( + {stage === "type" && (
@@ -470,36 +611,7 @@ export function SourceModal({ {!loading && ( <> - {!isEdit && ( - - )} - -
- - - - {t(type.labelKey)} - - - {t(type.descriptionKey)} - - -
- - + )} - {type.fields.map((field) => ( - renderField(field))} + + {advancedFields.length > 0 && ( + setShowAdvanced((v) => !v)} + header={t("portal.sources.builder.advanced")} > - {field.control === "s3Connection" ? ( - - setOption(field.key, connectionId) - } - onCreateNew={() => - openConnectionStage(field.key, S3_CONNECTION_TYPE) - } - /> - ) : field.control === "connection" ? ( - - setOption(field.key, connectionId) - } - integrationType={ - connectionTypeById(field.connectionTypeId ?? "") - .integrationType - } - createTypeId={field.connectionTypeId ?? ""} - presetId={field.connectionTypeId} - onCreateNew={() => - openConnectionStage( - field.key, - connectionTypeById(field.connectionTypeId ?? ""), - ) - } - /> - ) : field.control === "select" ? ( - setOption(field.key, e.target.value)} - /> - )} - - ))} +
+ {advancedFields.map((field) => renderField(field))} +
+ + )} {editingWebhookId && ( div:first-child { - align-self: stretch; } .portal-sources__connection-create-actions { @@ -169,6 +163,7 @@ cursor: pointer; font: inherit; color: inherit; + background: var(--c-surface-raised); } .portal-conn-picker__card:hover { diff --git a/frontend/editor/src/portal/components/sources/sourceTypes.ts b/frontend/editor/src/portal/components/sources/sourceTypes.ts index ee51ba21f3..c6196a4aab 100644 --- a/frontend/editor/src/portal/components/sources/sourceTypes.ts +++ b/frontend/editor/src/portal/components/sources/sourceTypes.ts @@ -74,6 +74,10 @@ export interface SourceFieldDef { helperTextKey?: string; options?: { value: string; labelKey: string }[]; defaultValue?: string; + /** Tucked behind the "Advanced" disclosure: power settings whose default suits almost everyone. */ + advanced?: boolean; + /** Only rendered while another field currently equals this value (e.g. a knob that only applies in one mode). */ + visibleWhen?: { key: string; equals: string }; /** * For `control: "connection"` - the connection-catalogue entry id this slot accepts (e.g. * "sftp"). Filters the picker to matching connections and pins the inline "new connection" form. @@ -117,6 +121,7 @@ function networkSourceFields(connectionTypeId: string): SourceFieldDef[] { control: "select", defaultValue: "consume", helperTextKey: "portal.sources.networkFields.mode.helperText", + advanced: true, options: [ { value: "consume", @@ -133,6 +138,7 @@ function networkSourceFields(connectionTypeId: string): SourceFieldDef[] { labelKey: "portal.sources.networkFields.recursive.label", control: "select", defaultValue: "false", + helperTextKey: "portal.sources.networkFields.recursive.helperText", options: [ { value: "false", @@ -168,6 +174,8 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ labelKey: "portal.sources.types.folder.fields.mode.label", control: "select", defaultValue: "consume", + helperTextKey: "portal.sources.types.folder.fields.mode.helperText", + advanced: true, options: [ { value: "consume", @@ -185,6 +193,8 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ labelKey: "portal.sources.types.folder.fields.recursive.label", control: "select", defaultValue: "false", + helperTextKey: + "portal.sources.types.folder.fields.recursive.helperText", options: [ { value: "false", @@ -204,6 +214,9 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ control: "select", defaultValue: "stat", helperTextKey: "portal.sources.types.folder.fields.identity.helperText", + advanced: true, + // Change detection only governs the consume ledger; snapshot re-reads everything regardless. + visibleWhen: { key: "mode", equals: "consume" }, options: [ { value: "stat", @@ -244,6 +257,7 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ control: "select", defaultValue: "consume", helperTextKey: "portal.sources.types.s3.fields.mode.helperText", + advanced: true, options: [ { value: "consume", diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx index 2925958efd..75da086806 100644 --- a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx +++ b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx @@ -215,22 +215,19 @@ const BIG_MEMBERS: Member[] = [ isSelf: true, portalAccess: "admin", }, - ...Array.from( - { length: 11 }, - (_, i): Member => ({ - id: `big-${i}`, - name: `Teammate ${i + 1}`, - email: `teammate${i + 1}@acme.com`, - role: i === 0 ? "team_owner" : "member", - status: "active", - lastActive: `${i + 1}h ago`, - username: `tm${i + 1}`, - teamId: 9, - teamName: "Platform", - teamLead: i === 0, - portalAccess: i === 0 ? "role" : "none", - }), - ), + ...Array.from({ length: 11 }, (_, i): Member => ({ + id: `big-${i}`, + name: `Teammate ${i + 1}`, + email: `teammate${i + 1}@acme.com`, + role: i === 0 ? "team_owner" : "member", + status: "active", + lastActive: `${i + 1}h ago`, + username: `tm${i + 1}`, + teamId: 9, + teamName: "Platform", + teamLead: i === 0, + portalAccess: i === 0 ? "role" : "none", + })), ]; const TEAMS: Team[] = [ diff --git a/frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts b/frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts new file mode 100644 index 0000000000..7b471447fb --- /dev/null +++ b/frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts @@ -0,0 +1,17 @@ +// Enterprise-license flag for the portal, from the backend app-config (`runningEE`). +// Gates Enterprise-only surfaces (e.g. Infrastructure > Audit) so they show a locked +// upsell instead of firing a doomed 403 request. The SaaS build shadows this file to +// derive enterprise from the plan tier (wallet-backed) - see portal-saas. + +import { useAppConfig } from "@app/contexts/AppConfigContext"; + +export interface EnterpriseState { + enabled: boolean; + // True while the flag is still resolving, so callers can hold rather than flash a lock. + loading: boolean; +} + +export function useEnterpriseEnabled(): EnterpriseState { + const { config, loading } = useAppConfig(); + return { enabled: Boolean(config?.runningEE), loading }; +} diff --git a/frontend/editor/src/portal/theme/mantineTheme.ts b/frontend/editor/src/portal/theme/mantineTheme.ts index e371b4aeb8..0c7afa94a7 100644 --- a/frontend/editor/src/portal/theme/mantineTheme.ts +++ b/frontend/editor/src/portal/theme/mantineTheme.ts @@ -128,7 +128,7 @@ export const suiCssVariablesResolver: CSSVariablesResolver = () => ({ light: { ...suiAccessibleColorSlots, // Popover/dropdown background + combobox search input - "--mantine-color-white": "var(--c-surface)", + "--mantine-color-white": "var(--c-surface-raised)", // Option hover background "--mantine-color-gray-0": "var(--c-hover)", // Dropdown border diff --git a/frontend/editor/src/portal/views/Documents.tsx b/frontend/editor/src/portal/views/Documents.tsx index f47abd3ffe..6bd394c13d 100644 --- a/frontend/editor/src/portal/views/Documents.tsx +++ b/frontend/editor/src/portal/views/Documents.tsx @@ -107,9 +107,13 @@ export function Documents() { - {/* Recorded policy-run failures. Not mounted outside dev: the endpoints are live and - gated, but the surface itself is unfinished (no polling, no paging, no filters). - Vite folds this to false in a build, so neither the view nor its fetch ships. */} + {/* Recorded policy, pipeline and editor failures. DEV ONLY, deliberately: this list is a + stand-in until failures get their own review screen, and it is not the surface we want to + ship. The endpoints behind it are live and gated, so nothing here is load-bearing. + + Vite folds this to false in a build, so neither the section nor its fetch ships. The bell's + "View in processor" action is gated the same way and for the same reason - lift both + together when the review screen lands, or that button navigates nowhere. */} {import.meta.env.DEV && }
); diff --git a/frontend/editor/src/portal/views/Infrastructure.test.tsx b/frontend/editor/src/portal/views/Infrastructure.test.tsx index cda36caef8..afd23e0ff1 100644 --- a/frontend/editor/src/portal/views/Infrastructure.test.tsx +++ b/frontend/editor/src/portal/views/Infrastructure.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; @@ -14,6 +14,11 @@ vi.mock("@portal/contexts/ViewContext", () => ({ useView: () => ({ setActiveView: vi.fn() }), })); +const enterprise = { enabled: true, loading: false }; +vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({ + useEnterpriseEnabled: () => enterprise, +})); + vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({ ApiKeysTab: () =>
, })); @@ -42,6 +47,11 @@ function tabButtons() { } describe("Infrastructure view", () => { + beforeEach(() => { + enterprise.enabled = true; + enterprise.loading = false; + }); + it("orders the working tabs first and defaults to API keys", () => { renderView(); @@ -91,4 +101,24 @@ describe("Infrastructure view", () => { expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument(); expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument(); }); + + it("disables the audit tab (inert, never opens) for non-enterprise users", () => { + enterprise.enabled = false; + renderView(); + + const auditBtn = screen.getByRole("button", { name: `${T}.audit` }); + expect(auditBtn).toBeDisabled(); + + fireEvent.click(auditBtn); + expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument(); + }); + + it("ignores a ?tab=audit deep link when not enterprise", () => { + enterprise.enabled = false; + renderView("/infrastructure?tab=audit"); + + expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/editor/src/portal/views/Infrastructure.tsx b/frontend/editor/src/portal/views/Infrastructure.tsx index 64ef579b55..fa1ff272b9 100644 --- a/frontend/editor/src/portal/views/Infrastructure.tsx +++ b/frontend/editor/src/portal/views/Infrastructure.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, Tabs, type TabItem } from "@app/ui"; import { useView } from "@portal/contexts/ViewContext"; +import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled"; import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab"; import { AuditTab } from "@portal/components/infrastructure/AuditTab"; import "@portal/views/Infrastructure.css"; @@ -12,30 +13,39 @@ type InfraTab = "api-keys" | "audit"; /** Shown but inert: no backend behind these screens yet. */ type DisabledInfraTab = "deployments" | "security" | "models" | "storage"; -const ENABLED_TABS: InfraTab[] = ["api-keys", "audit"]; - export function Infrastructure() { const { t } = useTranslation(); const [tab, setTab] = useState("api-keys"); const { setActiveView } = useView(); const [searchParams, setSearchParams] = useSearchParams(); + // Audit is Enterprise-only; disabled (greyed, inert) on non-enterprise instances. + const auditEnabled = useEnterpriseEnabled().enabled; + + const canOpenTab = useCallback( + (key: string) => key === "api-keys" || (key === "audit" && auditEnabled), + [auditEnabled], + ); // Deep-link (?tab=) from elsewhere (e.g. the home visualiser's outcome // cards → audit log): open that tab, then drop the param. useEffect(() => { const requested = searchParams.get("tab"); if (!requested) return; - if ((ENABLED_TABS as string[]).includes(requested)) { + if (canOpenTab(requested)) { setTab(requested as InfraTab); } const next = new URLSearchParams(searchParams); next.delete("tab"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, canOpenTab]); const tabs: TabItem[] = [ { key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") }, - { key: "audit", label: t("portal.infrastructure.tabs.audit") }, + { + key: "audit", + label: t("portal.infrastructure.tabs.audit"), + disabled: !auditEnabled, + }, { key: "deployments", label: t("portal.infrastructure.tabs.deployments"), @@ -78,7 +88,7 @@ export function Infrastructure() { items={tabs} activeKey={tab} onChange={(key) => { - if ((ENABLED_TABS as string[]).includes(key)) setTab(key as InfraTab); + if (canOpenTab(key)) setTab(key as InfraTab); }} variant="underline" ariaLabel={t("portal.infrastructure.sectionsAriaLabel")} diff --git a/frontend/editor/src/proprietary/billing/format.ts b/frontend/editor/src/proprietary/billing/format.ts index ff44e69882..3d6372ab6f 100644 --- a/frontend/editor/src/proprietary/billing/format.ts +++ b/frontend/editor/src/proprietary/billing/format.ts @@ -33,7 +33,7 @@ export function formatMinor( minor: number, currency: string | null | undefined, ): string { - const num = new Intl.NumberFormat(undefined, { + const num = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 3, }).format(minor / 100); @@ -45,7 +45,7 @@ export function formatMoneyMajor( major: number, currency: string | null | undefined, ): string { - return `${currencySymbol(currency)}${major.toLocaleString()}`; + return `${currencySymbol(currency)}${major.toLocaleString("en-US")}`; } /** diff --git a/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx b/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx new file mode 100644 index 0000000000..950a8d41ff --- /dev/null +++ b/frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx @@ -0,0 +1,271 @@ +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import type { + AppNotification, + NotificationActionOffer, +} from "@app/services/notifications"; +import type { NotificationActionContext } from "@core/components/notifications/notificationActions"; + +/** + * Where each action sends the reader. Only the editor has the workbench contexts above it, so the two + * shells are the interesting cases: opening the document, or handing it over. + */ + +const navigate = vi.fn(); +vi.mock("react-router-dom", async () => ({ + ...(await vi.importActual( + "react-router-dom", + )), + useNavigate: () => navigate, +})); + +// No i18n instance here, and the plugin is stubbed because the contexts below reach `core/i18n`. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback: string) => fallback, + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +// IndexedDB, which jsdom has none of. Answered here so presence is a fact of the test. +const h = vi.hoisted(() => ({ + stub: { id: "f-1" } as unknown, + getStirlingFileStub: vi.fn(), +})); + +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFileStub: (fileId: string) => h.getStirlingFileStub(fileId), + }, +})); + +const { FileActionsContext, FileStoreContext } = + await import("@app/contexts/file/contexts"); +const { NavigationActionsContext } = + await import("@app/contexts/NavigationContext"); +const { ViewerContext } = await import("@app/contexts/ViewerContext"); +const { useNotificationActions } = + await import("@app/components/notifications/notificationActions"); + +const addStirlingFileStubs = vi.fn(); +const setActiveFileId = vi.fn(); +const setWorkbench = vi.fn(); +/** What the workbench already holds, so the "do not add it twice" path can be exercised. */ +let openFileIds: string[] = []; + +function notification( + overrides: Partial = {}, +): AppNotification { + return { + id: "failure:evt-1", + source: "FAILURE", + kindId: "INPUT_PASSWORD_PROTECTED", + origin: "TOOL", + ownership: "MINE", + severity: "ERROR", + status: "NEW", + titleKey: "portal.failures.kind.inputPasswordProtected.title", + defaultTitle: "Password-protected document", + detail: "The PDF Document is passworded", + fileId: "f-1", + sourceId: null, + policyId: null, + occurrences: 3, + createdAt: "2026-08-06T00:00:00Z", + lastSeenAt: "2026-08-06T00:00:00Z", + actions: [], + ...overrides, + }; +} + +function offer(id: string): NotificationActionOffer { + return { + id, + labelKey: `portal.failures.action.${id.toLowerCase()}`, + defaultLabel: id, + enabled: true, + disabledReasonKey: null, + }; +} + +function context( + overrides: Partial = {}, +): NotificationActionContext { + return { + notification: notification(), + hasLocalFile: true, + ...overrides, + }; +} + +/** The editor shell: the workbench's providers all sit above the bell. */ +const inEditor = ({ children }: { children: ReactNode }) => ( + + + ({ files: { ids: openFileIds } }), + subscribe: () => () => {}, + selectors: {}, + } as never + } + > + + + {children} + + + + + +); + +/** The processor shell: the portal mounts above the app's providers, so there is none. */ +const inProcessor = ({ children }: { children: ReactNode }) => ( + {children} +); + +function registry(wrapper = inEditor) { + return renderHook(() => useNotificationActions(), { wrapper }).result.current; +} + +beforeEach(() => { + navigate.mockReset(); + addStirlingFileStubs.mockReset().mockResolvedValue([]); + setActiveFileId.mockReset(); + setWorkbench.mockReset(); + h.getStirlingFileStub.mockReset().mockResolvedValue(h.stub); + openFileIds = []; + window.sessionStorage.clear(); + window.history.pushState({}, "", "/"); +}); + +describe("useNotificationActions", () => { + it("offers to open the document only while it is still in this browser", () => { + const actions = registry(); + + expect(actions.VIEW_FILE?.available(context())).toBe(true); + expect(actions.VIEW_FILE?.available(context({ hasLocalFile: false }))).toBe( + false, + ); + }); + + it("leaves View file as the only usable offer when the server offers actions this build cannot run", () => { + // An id this build wires nothing for drops out rather than rendering dead. + const actions = registry(); + const usable = [offer("QUARANTINE"), offer("VIEW_FILE")].filter( + (candidate) => actions[candidate.id]?.available(context()) ?? false, + ); + + expect(usable.map((candidate) => candidate.id)).toEqual(["VIEW_FILE"]); + }); + + it("opens the document into the viewer when an editor is above", async () => { + await registry().VIEW_FILE?.run(context()); + + // Selecting alone shows nothing: the workbench holds neither the file nor the viewer yet. + expect(addStirlingFileStubs).toHaveBeenCalledWith([h.stub]); + expect(setActiveFileId).toHaveBeenCalledWith("f-1"); + expect(setWorkbench).toHaveBeenCalledWith("viewer"); + }); + + it("stays where it is rather than routing through the role-based root", async () => { + // "/" lands on a page chosen by the reader's role, which reads as the app reloading. + await registry().VIEW_FILE?.run(context()); + + expect(window.location.pathname).toBe("/"); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("does not add a document the workbench is already holding", async () => { + openFileIds = ["f-1"]; + + await registry().VIEW_FILE?.run(context()); + + expect(addStirlingFileStubs).not.toHaveBeenCalled(); + // Still brought to the front: the point of the click is to look at it. + expect(setActiveFileId).toHaveBeenCalledWith("f-1"); + expect(setWorkbench).toHaveBeenCalledWith("viewer"); + }); + + it("reports a document that has gone from storage instead of opening nothing", async () => { + h.getStirlingFileStub.mockResolvedValue(null); + + const outcome = await registry().VIEW_FILE?.run(context()); + + expect(outcome).toEqual({ ok: false }); + expect(setWorkbench).not.toHaveBeenCalled(); + }); + + it("hands the document over to the editor when there is no workbench above it", async () => { + await registry(inProcessor).VIEW_FILE?.run(context()); + + // The intent outlives the navigation that mounts the editor. + expect( + window.sessionStorage.getItem("stirling.notifications.pendingSelection"), + ).toBe("f-1"); + // The editor's own URL, not the role router at "/". + expect(window.location.pathname).toBe("/editor"); + }); + + it("picks up a handed-over document as soon as an editor is there", async () => { + window.sessionStorage.setItem( + "stirling.notifications.pendingSelection", + "f-9", + ); + + registry(); + await vi.waitFor(() => expect(setActiveFileId).toHaveBeenCalledWith("f-9")); + + expect(setWorkbench).toHaveBeenCalledWith("viewer"); + // One-shot: a later mount must not reopen a document the user has moved on from. + expect( + window.sessionStorage.getItem("stirling.notifications.pendingSelection"), + ).toBeNull(); + }); + + it("says it cannot hand the document over rather than navigating to nothing", async () => { + // Spied on the prototype: jsdom's storage is a proxy, so an own-property spy does not take. + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("denied"); + }); + + const outcome = await registry(inProcessor).VIEW_FILE?.run(context()); + + expect(outcome).toEqual({ + ok: false, + message: + "This browser will not let the processor pass the document to the editor. Open it from the editor instead.", + }); + // Still on the page it started on, so the failure is visible. + expect(window.location.pathname).toBe("/"); + setItem.mockRestore(); + }); + + it("links to the recorded failures section of the processor", () => { + registry().VIEW_IN_PROCESSOR?.run(context()); + + expect(navigate).toHaveBeenCalledWith("/processor/documents#failures"); + }); + + it("offers the processor link whenever the server did", () => { + // The server only sends it to someone it will let read the queue. + expect( + registry(inProcessor).VIEW_IN_PROCESSOR?.available( + context({ hasLocalFile: false }), + ), + ).toBe(true); + }); +}); diff --git a/frontend/editor/src/proprietary/components/notifications/notificationActions.ts b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts new file mode 100644 index 0000000000..5f34561a5a --- /dev/null +++ b/frontend/editor/src/proprietary/components/notifications/notificationActions.ts @@ -0,0 +1,155 @@ +import { useCallback, useContext, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { withBasePath } from "@app/constants/app"; +import { + FileActionsContext, + FileStoreContext, +} from "@app/contexts/file/contexts"; +import { NavigationActionsContext } from "@app/contexts/NavigationContext"; +import { ViewerContext } from "@app/contexts/ViewerContext"; +import { + PORTAL_BASENAME, + PORTAL_FAILURES_ANCHOR, +} from "@app/routes/portalBasename"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { fileStorage } from "@app/services/fileStorage"; +import type { FileId } from "@app/types/file"; +import { + type ClientActionOutcome, + type ClientActionRegistry, + type ClientActionSpec, + type NotificationActionContext, +} from "@core/components/notifications/notificationActions"; + +export { + type ClientActionOutcome, + type ClientActionRegistry, + type ClientActionSpec, + type NotificationActionContext, +}; + +/** + * The portal mounts as a sibling of `AppProviders`, so in the processor shell none of the workbench + * contexts exist above this hook. That is why contexts are read raw and a document is handed over. + */ + +const HANDOFF_KEY = "stirling.notifications.pendingSelection"; + +const FAILURES_DESTINATION = `${PORTAL_BASENAME}/documents#${PORTAL_FAILURES_ANCHOR}`; + +/** False when storage refused it: navigating anyway lands the user in an editor with nothing open. */ +function stashSelection(fileId: string): boolean { + try { + window.sessionStorage.setItem(HANDOFF_KEY, fileId); + return true; + } catch { + return false; + } +} + +function takeSelection(): string | null { + try { + const fileId = window.sessionStorage.getItem(HANDOFF_KEY); + if (fileId !== null) window.sessionStorage.removeItem(HANDOFF_KEY); + return fileId; + } catch { + return null; + } +} + +/** + * Not the router's `navigate`: the editor reads its tool from the URL on mount and on a history pop, + * and a router push is neither, so the address would change and the workbench would not. + */ +function goToEditor(path: string): void { + window.history.pushState({}, "", withBasePath(path)); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function useNotificationActions(): ClientActionRegistry { + const { t } = useTranslation(); + const navigate = useNavigate(); + // Raw, because the hooks that wrap these throw when there is no provider, and in the processor + // shell there is none. All four are present together or not at all. + const fileContext = useContext(FileActionsContext); + const fileStore = useContext(FileStoreContext); + const navigation = useContext(NavigationActionsContext); + const viewer = useContext(ViewerContext); + const canOpenHere = Boolean(fileContext && fileStore && navigation && viewer); + + /** + * Opens the way the file sidebar does. Selecting alone shows nothing: an id the workbench does not + * hold has nothing to render, and the workbench keeps whatever view it was on. + */ + const openInWorkbench = useCallback( + async (fileId: string): Promise => { + if (!fileContext || !fileStore || !navigation || !viewer) return false; + + const stub = await fileStorage.getStirlingFileStub(fileId as FileId); + if (!stub) return false; + + const alreadyOpen = fileStore + .getState() + .files.ids.some((id) => (id as string) === fileId); + if (!alreadyOpen) { + await fileContext.actions.addStirlingFileStubs([stub]); + } + viewer.setActiveFileId(fileId); + navigation.actions.setWorkbench("viewer"); + return true; + }, + [fileContext, fileStore, navigation, viewer], + ); + + // One-shot: read and cleared, so a later render cannot reopen a file the user has moved on from. + useEffect(() => { + if (!canOpenHere) return; + const fileId = takeSelection(); + if (fileId) void openInWorkbench(fileId); + }, [canOpenHere, openInWorkbench]); + + return useMemo(() => { + const openDocument = async ( + fileId: string | null, + ): Promise => { + if (!fileId) return; + + // In place, with no navigation: "/" is the role-based router, so going there reads as the app + // reloading and lands the user wherever their role says rather than on their document. + if (canOpenHere) { + return (await openInWorkbench(fileId)) ? undefined : { ok: false }; + } + + if (!stashSelection(fileId)) { + return { + ok: false, + message: t( + "notifications.handoffUnavailable", + "This browser will not let the processor pass the document to the editor. Open it from the editor instead.", + ), + }; + } + goToEditor(EDITOR_BASENAME); + }; + + const viewFile: ClientActionSpec = { + available: (context) => context.hasLocalFile, + closesPanel: true, + run: (context) => openDocument(context.notification.fileId), + }; + + const viewInProcessor: ClientActionSpec = { + // Its destination is dev-only until failures get a review screen; the other half of this gate + // is in portal/views/Documents, and both lift together. + available: () => import.meta.env.DEV, + closesPanel: true, + run: () => navigate(FAILURES_DESTINATION), + }; + + return { + VIEW_FILE: viewFile, + VIEW_IN_PROCESSOR: viewInProcessor, + }; + }, [canOpenHere, openInWorkbench, navigate, t]); +} diff --git a/frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts b/frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts new file mode 100644 index 0000000000..2e21f0f33e --- /dev/null +++ b/frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts @@ -0,0 +1,7 @@ +/** + * This build ships the failure registry and the notification routes, so the bell has + * something to read and may mount. + */ +export function useNotificationsAvailable(): boolean { + return true; +} diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts index aac4b7e3da..64fd849d87 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts @@ -1,5 +1,5 @@ -// With the AI engine off, the Classification policy runs here in the browser: -// each upload is labelled by the heuristic engine and metered for billing parity. +// The Classification policy's first pass: every upload is labelled locally before the AI is asked. +// The confidence reported here decides whether the AI is asked at all - see usePolicyAutoRun. import { useEffect, useRef, useState } from "react"; import { useAllFiles, useFileManagement } from "@app/contexts/FileContext"; @@ -7,7 +7,6 @@ import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { fileStorage } from "@app/services/fileStorage"; import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled"; -import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled"; import { scheduleIdle } from "@app/utils/scheduleIdle"; import { usePolicies } from "@app/hooks/usePolicies"; import { classifyFileHeuristically } from "@app/services/heuristic/heuristicClassification"; @@ -15,12 +14,14 @@ import { meterClassificationRun } from "@app/services/classificationMeter"; import { isDispatched, markDispatched, + recordRunStart, + updateRun, } from "@app/components/policies/policyRunStore"; import type { FileId } from "@app/types/file"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; +import type { HeuristicConfidence } from "@app/services/heuristic/types"; +import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy"; -/** The category id of the Classification policy (see policyDefinitions). */ -const CLASSIFICATION_CATEGORY = "classification"; /** Files classified per idle pass, so a large library drains over several ticks. */ const CLASSIFY_BATCH = 3; /** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s). @@ -47,9 +48,8 @@ export function useClientSideClassification(): void { const { bumpRevision } = useIndexedDB(); const { policies } = usePolicies(); const classificationEnabled = useClassificationEnabled(); - const aiEnabled = useAiEngineEnabled(); - // While app-config loads, aiEnabled reads false even on AI-on tenants; classifying - // in that window would double-run (and double-bill) files the server also labels. + // Still waited on: a verdict written before app-config lands would be acted on by the + // escalation decision before it knows whether the AI engine is even available. const { loading: configLoading } = useAppConfig(); // Files claimed this session, keyed id+lastModified so a new version is retried once. A claim is // taken synchronously right before classifying, so overlapping batches never double-classify. @@ -57,7 +57,9 @@ export function useClientSideClassification(): void { // Bumped after each batch to drain the next one. const [tick, setTick] = useState(0); - const policy = policies[CLASSIFICATION_CATEGORY]; + // TODO: keyed on the Classification CATEGORY, so a pipeline that merely contains a classify + // step gets no local pass - suppressing one step of a chain is not expressible today. + const policy = policies[CLASSIFICATION_CATEGORY_ID]; // Only when the admin has an active Classification policy - the same gate the AI path uses. const active = Boolean( policy?.configured && @@ -69,7 +71,8 @@ export function useClientSideClassification(): void { ); useEffect(() => { - if (configLoading || !classificationEnabled || aiEnabled || !active) { + // Runs whether or not the AI engine is on: it is the first pass either way, not a fallback. + if (configLoading || !classificationEnabled || !active) { return; } const claimKey = (s: StirlingFileStub) => @@ -95,17 +98,23 @@ export function useClientSideClassification(): void { // Re-validate at execution time - another batch may have claimed it since. if (claimed.current.has(key)) continue; claimed.current.add(key); - const labels = await classifyStub(stub.id as FileId, stub.name); + const verdict = await classifyStub( + stub.id as FileId, + stub.name, + stub.size ?? 0, + ); // Bytes never landed (file removed mid-wait): leave undelivered so a // reload (or new version) retries; the claim stops churn this session. - if (labels == null) continue; + if (verdict == null) continue; // Deliver unconditionally - a re-render must never discard a computed // (and already metered) result. Writes are idempotent. updateStirlingFileStub(stub.id as FileId, { - classificationLabels: labels, + classificationLabels: verdict.labels, + classificationConfidence: verdict.confidence, }); const ok = await fileStorage.updateFileMetadata(stub.id as FileId, { - classificationLabels: labels, + classificationLabels: verdict.labels, + classificationConfidence: verdict.confidence, }); if (ok) wrote = true; } @@ -122,7 +131,6 @@ export function useClientSideClassification(): void { fileStubs, active, classificationEnabled, - aiEnabled, configLoading, updateStirlingFileStub, bumpRevision, @@ -134,7 +142,8 @@ export function useClientSideClassification(): void { async function classifyStub( fileId: FileId, fileName: string, -): Promise { + fileSize: number, +): Promise<{ labels: string[]; confidence: HeuristicConfidence } | null> { let file: StirlingFile | null = null; for (let i = 0; i < FILE_WAIT_TRIES; i++) { file = await fileStorage.getStirlingFile(fileId).catch(() => null); @@ -149,10 +158,28 @@ async function classifyStub( } const debug = isClassificationDebug(); const startedAt = performance.now(); + // A local run is still a billable policy run, so it belongs in the activity feed; recorded only + // once the bytes are in hand, so a file whose bytes never land leaves no phantom row. + + // Read before recordRunStart, which takes the dispatch key itself and would otherwise always + // answer "already dispatched", silently stopping metering. + const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`; + recordRunStart({ + runId, + categoryId: CLASSIFICATION_CATEGORY_ID, + fileId: fileId as string, + fileName, + fileSize, + target: "local", + status: "RUNNING", + outputs: [], + error: null, + startedAt: Date.now(), + }); try { const result = await classifyFileHeuristically(file, { explain: debug }); const { labels } = result; - const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY, fileId); const ms = Math.round(performance.now() - startedAt); const verdict = labels.length > 0 @@ -174,12 +201,22 @@ async function classifyStub( labels, }); } - markDispatched(CLASSIFICATION_CATEGORY, fileId); - return labels; + markDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + // Labels, no output file - the same settle shape the server-run classification uses. + updateRun(runId, { + status: "COMPLETED", + imported: true, + outputFileIds: [fileId as string], + }); + return { labels, confidence: result.confidence }; } catch (err) { // Never persist a verdict for an unreadable file - the failure may be // environmental, so it must stay eligible to retry (and meter) later. console.warn(`[Classify] ${fileName}: could not be read, will retry`, err); + updateRun(runId, { + status: "FAILED", + error: err instanceof Error ? err.message : String(err), + }); return null; } } diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx index bc4fa54dff..08768f1aa7 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import type { ClassificationConfidence } from "@app/types/fileContext"; /** * Batch integration test (61 files, two chained upload policies) driving the real @@ -12,7 +13,11 @@ const FILE_COUNT = 61; // the workbench, mirrored into useAllFiles. consumeFiles mutates it in place // (input id → output id) exactly as the real silent reducer would. const mocks = vi.hoisted(() => ({ - workspace: [] as Array<{ id: string; classificationLabels?: string[] }>, + workspace: [] as Array<{ + id: string; + classificationLabels?: string[]; + classificationConfidence?: ClassificationConfidence; + }>, consumeSilentCalls: 0, consumeNonSilentCalls: 0, persistCalls: 0, @@ -117,10 +122,20 @@ function Harness() { return null; } +/** The heuristic verdict that escalates to the AI classifier; only "high" stands alone. */ +const LOW = "low" as const; + function replaceInWorkspace(inputIds: string[], outputIds: string[]) { + // A versioned output carries its input's heuristic verdict; the escalation decision is about the + // document, not about which step produced the current bytes. + const inherited = + mocks.workspace.find((s) => inputIds.includes(s.id)) + ?.classificationConfidence ?? LOW; mocks.workspace = mocks.workspace .filter((s) => !inputIds.includes(s.id)) - .concat(outputIds.map((id) => ({ id }))); + .concat( + outputIds.map((id) => ({ id, classificationConfidence: inherited })), + ); } beforeEach(() => { @@ -138,6 +153,7 @@ beforeEach(() => { mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({ id: `file-${i}`, + classificationConfidence: LOW, })); mocks.listPolicyRuns.mockResolvedValue([]); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx index b87cbda445..a6f78392be 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx @@ -91,7 +91,12 @@ describe("auto-run ordered chaining", () => { // The first policy (order 0) runs on the upload; the second waits for the chain. expect(runStored).toHaveBeenCalledTimes(1); - expect(runStored).toHaveBeenCalledWith("backend-sec", [{ size: 100 }]); + // Recorded against a document this browser can resolve, which is what makes its failure actionable. + expect(runStored).toHaveBeenCalledWith( + "backend-sec", + [{ size: 100 }], + "file-1", + ); }); it("chains the next policy onto a completed run's output", async () => { @@ -120,8 +125,12 @@ describe("auto-run ordered chaining", () => { await vi.advanceTimersByTimeAsync(1); }); - // The next policy (order 1) fires on the first policy's output, not the original. - expect(runStored).toHaveBeenCalledWith("backend-cls", [{ size: 100 }]); + // Fires on the first policy's output and reports that output's own id, not the original's. + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 100 }], + "file-1-v2", + ); }); it("keeps classification out of the server chain when the AI engine is off", async () => { @@ -136,10 +145,31 @@ describe("auto-run ordered chaining", () => { await vi.advanceTimersByTimeAsync(1); }); - expect(runStored).toHaveBeenCalledWith("backend-sec", [{ size: 100 }]); + expect(runStored).toHaveBeenCalledWith( + "backend-sec", + [{ size: 100 }], + "file-1", + ); expect(runStored).not.toHaveBeenCalledWith( "backend-cls", expect.anything(), + expect.anything(), ); }); + + it("never dispatches on a file marked derivedFromTool", async () => { + // A policy run is billed, so this gate is what stops `importOutputs` re-enforcing a policy on + // its own output forever. If this fails, fix the gate rather than the test. + setFileStubs([ + { id: "file-1", name: "unlocked.pdf", derivedFromTool: true }, + ]); + runStored.mockResolvedValue("run-sec"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(runStored).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx index b67648e7fc..84b6f58bf7 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import type { ClassificationConfidence } from "@app/types/fileContext"; /** * Mid-run race: classification is in flight (its labelled output is still @@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({ sourceFileIds?: string[]; derivedFromTool?: boolean; classificationLabels?: string[]; + classificationConfidence?: ClassificationConfidence; }>, runStoredPolicy: vi.fn(), getPolicyRun: vi.fn(), @@ -114,7 +116,7 @@ beforeEach(() => { resetPolicyRuns(); vi.clearAllMocks(); - mocks.workspace = [{ id: "file-0" }]; + mocks.workspace = [{ id: "file-0", classificationConfidence: "low" }]; mocks.listPolicyRuns.mockResolvedValue([]); mocks.getStirlingFile.mockResolvedValue( diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx index 3c54e5cc42..b837b4beba 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx @@ -113,7 +113,13 @@ describe("auto-run queue-rejection retry", () => { await act(async () => { await vi.advanceTimersByTimeAsync(6000); }); - expect(runStored).toHaveBeenCalledWith("backend-1", [{ size: 1234 }]); + // The workspace id travels with the retry too, so a failure of it can still name the + // document this browser is holding. + expect(runStored).toHaveBeenCalledWith( + "backend-1", + [{ size: 1234 }], + "file-1", + ); expect(getRun("run-1")).toBeUndefined(); expect(getRun("run-2")?.status).toBe("RUNNING"); }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 7a2c4bda2e..936fef8119 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -1,18 +1,6 @@ /** - * Auto-run controller: every enabled policy enforces on every uploaded file. - * Watches the session's files and fires a real backend run - * (`POST /api/v1/policies/{id}/run`) per file, polling it to completion and - * recording progress in {@link policyRunStore} for the activity feed. - * - * When several policies enforce on the same trigger they run as an ordered chain: - * the first fires on the upload, and each subsequent policy fires on the previous - * one's output once it lands — so their effects accumulate in the admin-defined - * order rather than racing to fork the same version. - * - * Headless — call it from {@link PolicyAutoRunController}, which is mounted once - * wherever the editor is open so enforcement happens regardless of whether the - * policy panel is on screen. Each (policy, file) pair runs exactly once (tracked - * in the run store), so re-renders and remounts don't re-fire. + * Headless auto-run controller: one backend run per (policy, file), fired exactly once and polled. + * Policies sharing a trigger run as an ordered chain so their effects accumulate. */ import { useCallback, useEffect, useMemo, useRef } from "react"; @@ -22,6 +10,7 @@ import { useFileContext, } from "@app/contexts/FileContext"; import { fileStorage } from "@app/services/fileStorage"; +import { refreshNotificationsNow } from "@app/hooks/useNotifications"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import i18n from "@app/i18n"; import { @@ -39,7 +28,12 @@ import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import type { FileId } from "@app/types/file"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { readClassificationLabelsFromFile } from "@app/services/fileClassification"; -import { isClassificationCategory } from "@app/data/policyCategories"; +import { + policyDeliversOutputFiles, + policyRequiresAiEngine, + policyRewritesDocument, + shouldDispatchToAi, +} from "@app/data/classificationPolicy"; import { acquireDispatchSlot, releaseDispatchSlot, @@ -68,10 +62,8 @@ const POLL_MS = 2000; * sitting on an indeterminate spinner for a full poll interval. */ const FIRST_POLL_MS = 500; -/** The server aborts any single tool step that runs longer than its internal-API - * read timeout, then fails the run — so a run can legitimately stay in flight - * for up to this long per step. The client must keep polling at least that long, - * or it abandons a run the server is still working on (which reads as a hang). */ +/** Server's per-step abort budget - poll at least this long per step, or we abandon + * a run the server is still working on. */ const STEP_TIMEOUT_MS = 300_000; /** Slack on top of the per-step budget: queueing before the first step starts and @@ -91,11 +83,8 @@ const POLICY_QUEUE_FULL = "POLICY_QUEUE_FULL"; const MAX_QUEUE_RETRIES = 5; const QUEUE_RETRY_BASE_MS = 4000; -/** Consecutive "run not found" responses before giving up. The run state lives - * in memory on the server, so a restart or a second instance behind the load - * balancer makes a live run's status return 404 — and it won't come back. We - * tolerate a brief blip (e.g. a poll racing a just-dispatched run, or one hop - * to an instance that hasn't seen it) then fail, rather than polling forever. */ +/** Consecutive 404s before failing. Run state is in-memory server-side, so a restart + * or a hop to another instance loses it permanently - tolerate a blip, not forever. */ const MAX_NOT_FOUND = 3; /** A 404 (run status gone, or output file gone), across the web (axios) and @@ -118,24 +107,20 @@ function failRun(runId: string, message: string): void { updateRun(runId, { status: "FAILED", error: message, errorCode: null }); } -/** How long to wait for an upload's bytes to land in IndexedDB before giving up - * (20 × 250ms ≈ 5s). The stub can surface in the file list a beat before its - * bytes are committed, so a too-eager fetch would otherwise miss the file. */ +/** Wait for an upload's bytes to land in IndexedDB (~5s): the stub surfaces in the + * file list before its bytes are committed, so an eager fetch would miss the file. */ const FILE_WAIT_TRIES = 20; const FILE_WAIT_MS = 250; -/** - * A policy that changed nothing (redaction matched no text, say) completes with no - * output: nothing to deliver, but finished. Left unimported, the file's badge and - * its blocking overlay spin forever. - */ +/** A policy that changed nothing completes with no output; left unimported its badge + * and blocking overlay spin forever. */ export function finishedWithNothingToDeliver(run: PolicyRunRecord): boolean { return ( run.status === "COMPLETED" && !run.imported && (run.outputs?.length ?? 0) === 0 && - // Classification has its own settle path: labels, no output file. - !isClassificationCategory(run.categoryId) + // An annotating policy settles on labels, not an output file. + policyDeliversOutputFiles(run.categoryId) ); } @@ -155,34 +140,23 @@ export function usePolicyAutoRun(): void { const { policies } = usePolicies(); const aiEnabled = useAiEngineEnabled(); const runs = usePolicyRuns(); - // Live view of the workspace files, read inside the import effect WITHOUT making - // it a dependency. The silent consume that delivers an output mutates fileStubs, - // so if the import effect depended on fileStubs it would re-fire on its own - // delivery — an infinite import cascade (and a bumpRevision storm that trips - // React's max-update-depth). The effect only needs to fire when `runs` changes. + // Read in the import effect via ref, not as a dependency: delivery mutates fileStubs, + // so depending on them would re-fire the effect on its own delivery (infinite cascade). const fileStubsRef = useRef(fileStubs); fileStubsRef.current = fileStubs; - // Keys (run ids / dispatch keys) currently in flight, so the effects never - // double-fire across re-renders while their first async step is pending. + // Keys in flight, so effects never double-fire across re-renders while async work pends. const polling = useRef>(new Set()); const importing = useRef>(new Set()); const dispatching = useRef>(new Set()); // Reconcile against the backend exactly once per mount. const reconciled = useRef(false); - // A policy's tool calls run server-side, so a usage-limit 402 never reaches the apiClient - // interceptor (and thus never pops the modal that direct calls get). The backend surfaces the - // limit sentinel on the run's errorCode; when a run we polled finishes blocked, broadcast a - // window event. A saas-layer listener (which can read the wallet + open the modal — this - // proprietary hook can't import the saas modal API) decides free-limit vs spend-cap. Dedupe per - // run so a folder-watch burst opens the modal once, not once per file. + // Server-side runs never hit the apiClient 402 interceptor, so we broadcast the limit + // sentinel for a saas listener to open the modal. Deduped per run. const firedLimitModal = useRef>(new Set()); - // Active upload policies in execution order. When several enforce on upload they - // run as a chain — the first fires on the upload, each subsequent one on the - // previous policy's output — so their effects accumulate in a defined order - // instead of racing to fork the same version. Mirrors the dispatch filter - // (incl. the editor-source gate) so the chain honours the same eligibility. + // Active upload policies in chain order, so effects accumulate instead of racing to fork + // the same version. Mirrors the dispatch filter so the chain honours the same eligibility. const orderedUploadCategories = useMemo( () => Object.entries(policies) @@ -195,41 +169,40 @@ export function usePolicyAutoRun(): void { s.sources.length === 0 || s.sources.includes("editor")) && (s.runOn ?? "upload") === "upload" && - // Non-AI systems classify in the browser (useClientSideClassification), so keep the - // Classification policy out of the server chain when the AI engine is off. - !(id === "classification" && !aiEnabled), + // An escalation-only policy has nothing to do with no engine to escalate to. + !(policyRequiresAiEngine(id) && !aiEnabled), ) - // Classification runs last: it's non-blocking, so an enforcement policy - // running after it would fork a new version and drop the user's edits. + // Annotating policies run last: a rewriting one after them would fork a new + // version from the pre-annotation state and drop their labels. .sort(([idA, a], [idB, b]) => { - const ca = isClassificationCategory(idA) ? 1 : 0; - const cb = isClassificationCategory(idB) ? 1 : 0; - if (ca !== cb) return ca - cb; + const ra = policyRewritesDocument(idA) ? 0 : 1; + const rb = policyRewritesDocument(idB) ? 0 : 1; + if (ra !== rb) return ra - rb; return (a.order ?? 0) - (b.order ?? 0); }) .map(([id]) => id), [policies, aiEnabled], ); - // Runs whose chain-continuation we've already handled this session, so the next - // policy is dispatched exactly once per completed run. + // Chain-continuations handled this session, so the next policy fires once per run. const chained = useRef>(new Set()); // Latest policies, read from inside the stable retry callback (which has no deps). const policiesRef = useRef(policies); policiesRef.current = policies; + // Latest stubs for the chaining effect, which keys off runs and must not depend on stubs. + const stubsRef = useRef(fileStubs); + stubsRef.current = fileStubs; // Per-file (dispatchKey) count of consecutive queue-rejection retries, so backoff escalates and // eventually gives up. Survives the run-id changing on each retry; reset on any real outcome. const queueRetries = useRef>(new Map()); - // A queue-rejected run is just backpressure — drop the rejected record and fire a fresh run in - // its place after a growing backoff (one feed row, not a new one per attempt). Once the budget is - // spent, leave the last failure standing so the activity feed offers a manual Retry. + // Queue rejection is backpressure: replace the record with a fresh run after a backoff, so the + // feed keeps one row. Budget spent, leave the failure standing for a manual Retry. const scheduleQueueRetry = useCallback((runId: string) => { const rec = getRun(runId); if (!rec) return; - // A run rediscovered from the server (reconciled) has no local input fileId, so it can't be - // re-dispatched; leave it failed rather than spinning on a file we can't resolve. + // A reconciled run has no local fileId to re-dispatch; leave it failed. if (!rec.fileId) return; const key = dispatchKey(rec.categoryId, rec.fileId); const attempts = queueRetries.current.get(key) ?? 0; @@ -269,6 +242,8 @@ export function usePolicyAutoRun(): void { dispatchKey(finished.categoryId, finished.fileId), ); } + // Read now rather than leaving them a poll interval to hear about their own upload. + if (view.status === "FAILED") refreshNotificationsNow(); const code = view.errorCode; if (code !== "PAYG_LIMIT_REACHED" && code !== "FEATURE_DEGRADED") return; if (firedLimitModal.current.has(view.runId)) return; @@ -278,42 +253,38 @@ export function usePolicyAutoRun(): void { [scheduleQueueRetry], ); - // Dispatch: fire only the FIRST upload policy on each not-yet-run file. The rest - // of the chain is dispatched by the chaining effect below, each on the previous - // policy's output, so the policies apply cumulatively in order. + // Fire only the FIRST upload policy per file; the chaining effect below runs the rest + // on each previous output, so policies apply cumulatively in order. useEffect(() => { const firstCategory = orderedUploadCategories[0]; if (!firstCategory) return; const backendId = policies[firstCategory]?.backendId; if (!backendId) return; for (const stub of fileStubs) { - // Input-mode policies enforce only on files that actually entered the - // system as an upload — not on files a tool/automation produced in-app - // (versioned edits or independent artifacts like convert/split/merge). - // Those are enforced only by export-mode policies, at export time. + // Input-mode policies cover uploads only; tool-produced files are left to + // export-mode policies at export time. if (stub.derivedFromTool) continue; const key = dispatchKey(firstCategory, stub.id); - // Skip if already run (persisted) or a dispatch is in flight — the - // in-memory guard prevents double-firing during the async wait. + // Skip if already run (persisted) or in flight - the in-memory guard covers the async wait. if ( isDispatched(firstCategory, stub.id) || dispatching.current.has(key) ) { continue; } + // A confident local verdict stands; only an unsure one is escalated to the engine. + if (!shouldDispatchToAi(firstCategory, stub)) continue; dispatching.current.add(key); void runPolicyOnFile(firstCategory, backendId, stub.id, stub.name) .catch(() => { - // runPolicyOnFile handles its own failures; this is just a backstop - // so an unexpected rejection never becomes an unhandled rejection. + // Backstop: runPolicyOnFile handles its own failures. }) .finally(() => dispatching.current.delete(key)); } }, [fileStubs, policies, orderedUploadCategories]); - // Chain: once a run has completed AND its output landed in the workspace, fire the - // next upload policy on that output. Only chains on success (a failed run has no - // output), and only once per run. isDispatched guards re-dispatch across reloads. + // Once a run's output lands, fire the next upload policy on it - success only, once per + // run. isDispatched guards re-dispatch across reloads. useEffect(() => { for (const run of runs) { if (run.status !== "COMPLETED" || !run.imported) continue; @@ -332,10 +303,15 @@ export function usePolicyAutoRun(): void { // Next policy not ready yet (still reconciling) — retry when policies change. if (!backendId) continue; chained.current.add(run.runId); - // Chain onto EVERY output, not just the first — a run that produced multiple files (split, - // ZIP-unpacked) must apply the next policy to all of them, or outputs 2..N silently skip it. + // Chain onto EVERY output: a run that produced several files (split, ZIP-unpacked) + // would otherwise silently skip the next policy on outputs 2..N. for (const outputId of outputIds) { if (isDispatched(nextCategory, outputId as FileId)) continue; + const outputStub = stubsRef.current.find((s) => s.id === outputId); + // Nothing to escalate: either the heuristic already answered confidently, or it has + // not reported yet and this effect re-runs when the verdict lands. + if (outputStub && !shouldDispatchToAi(nextCategory, outputStub)) + continue; void runPolicyOnFile( nextCategory, backendId, @@ -345,7 +321,7 @@ export function usePolicyAutoRun(): void { ).catch(() => {}); } } - }, [runs, policies, orderedUploadCategories]); + }, [runs, policies, orderedUploadCategories, fileStubs]); // Poll each in-flight run to a terminal state. useEffect(() => { @@ -358,11 +334,10 @@ export function usePolicyAutoRun(): void { } }, [runs, onRunFinished]); - // Import each completed run's outputs into the workspace (each output once), - // so the enforced file appears in the app rather than only on the backend. + // Import each completed run's outputs once, so the enforced file appears in the app. useEffect(() => { for (const run of runs) { - const classification = isClassificationCategory(run.categoryId); + const deliversFiles = policyDeliversOutputFiles(run.categoryId); if ( run.status !== "COMPLETED" || run.imported || @@ -375,13 +350,10 @@ export function usePolicyAutoRun(): void { continue; } importing.current.add(run.runId); - // Classification is metadata-only: stamp labels onto the current leaf of - // the file it ran on (no version fork). See importClassificationLabels. - if (classification) { - // Targets are resolved by importClassificationLabels AT WRITE TIME (not - // snapshotted here): its download/parse is an async window during which - // a manual tool run can consume the input and fork a new leaf, and a - // stale snapshot would no-op on the dead id and lose the labels. + // An annotating policy writes labels onto the current leaf; no version fork. + if (!deliversFiles) { + // Resolved at write time, not snapshotted: a tool run during the async parse can fork + // a new leaf, and a stale id would no-op and lose the labels. void importClassificationLabels( run, () => @@ -390,8 +362,7 @@ export function usePolicyAutoRun(): void { ).finally(() => importing.current.delete(run.runId)); continue; } - // Honour the policy's output mode: a new file, or a new version of the - // input file it ran on (needs that input's stub, still in the workspace). + // Output mode: a new file, or a new version of the input (needs its stub in the workspace). const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; const outputName = policies[run.categoryId]?.outputName ?? ""; const outputNamePosition = policies[run.categoryId]?.outputNamePosition; @@ -410,9 +381,8 @@ export function usePolicyAutoRun(): void { firstUploadCategory: orderedUploadCategories[0], }).finally(() => importing.current.delete(run.runId)); } - // NB: fileStubs is intentionally NOT a dependency — it's read via a ref so a - // delivery's own workspace mutation can't re-trigger this effect (see the ref - // declaration above). The effect fires on run completions, which is all it needs. + // NB: fileStubs is read via a ref, not a dependency, so a delivery's own workspace + // mutation can't re-trigger this effect. }, [ runs, addFiles, @@ -422,11 +392,8 @@ export function usePolicyAutoRun(): void { orderedUploadCategories, ]); - // Reconcile against the backend on load. The server owns runs (durable, user-scoped), - // so a run started before this client recorded it, or before a refresh/crash, is - // rediscovered here; the poll + import effects above then collect its outputs rather - // than leaving them orphaned. Waits until policies are known so server runs can be - // attributed to their category. + // The server owns runs, so rediscover any this client never recorded and let the effects + // above collect their outputs. Waits for policies so runs can be attributed to a category. useEffect(() => { if (reconciled.current) return; if (Object.keys(policies).length === 0) return; @@ -457,23 +424,18 @@ interface ImportContext { outputMode: "new_file" | "new_version"; /** Rename rule. Empty → keep the input's filename. */ outputName: string; - /** Where the rename is applied: before ("prefix") or after ("suffix") the - * base filename. Defaults to "suffix" when absent. */ + /** Rename position around the base filename; defaults to "suffix" when absent. */ outputNamePosition?: "prefix" | "suffix" | "auto-number"; /** The input file's stub — required to version it; absent if it's been removed. */ parentStub: StirlingFileStub | undefined; - /** The first upload policy in the chain — the only one the dispatch effect ever - * fires. Every policy output is marked dispatched for it so a downstream policy's - * output is never mistaken for a fresh upload and re-enforced (an endless loop). */ + /** The only policy the dispatch effect fires; every output is marked dispatched for it + * so a downstream output is never mistaken for a fresh upload and re-enforced. */ firstUploadCategory: string | undefined; } /** - * Pull the caller's server-side runs and fold them into the local store. For a run we already - * track, patch its status/outputs (preserving local import progress + attribution); for one we - * don't, adopt it for feed visibility (polled if still live, but never auto-imported — see the - * `imported` note below). Server-excluded ad-hoc runs and runs we can't map to a configured - * category are skipped. + * Fold server-side runs into the local store: patch tracked ones, adopt untracked ones for feed + * visibility only. Unmappable and ad-hoc runs are skipped. */ function applyOutputName( inputFileName: string, @@ -489,8 +451,7 @@ function applyOutputName( : `${base}_${outputName}${ext}`; } -/** The next upload policy after {@code categoryId} in the chain, or undefined if - * it's last or no longer in the ordered set (e.g. paused since it ran). */ +/** Next upload policy in the chain, or undefined if last or no longer eligible. */ function nextUploadCategory( orderedUploadCategories: string[], categoryId: string, @@ -522,13 +483,11 @@ async function reconcileServerRuns( addReconciledRun({ runId: view.runId, categoryId, - // No local input link: a run rediscovered purely from the server was never recorded by - // this client, so it can't be tied back to a workspace/storage file (and isn't retried). + // Server-only run: never recorded here, so it can't be tied to a file (and isn't retried). fileId: "", fileName: view.outputs[0]?.fileName ?? "", fileSize: 0, - // Rediscovered from the SaaS run registry (listPolicyRuns), so its outputs - // live on the cloud backend. + // From the SaaS run registry, so its outputs live on the cloud backend. target: "saas", status: view.status, outputs: view.outputs, @@ -560,10 +519,8 @@ interface ClassificationImportContext { bumpRevision: () => void; } -/** Workspace stubs to tag with a classification run's labels: the file it ran - * on plus any live descendants, so an edit made during the async run (which - * forks a new leaf) still shows the tags. Empty once the document has left the - * workspace (closed, or a reconciled run with no local input link). */ +/** The run's file plus live descendants, so an edit during the run (which forks a new leaf) + * still shows the tags. Empty once the document has left the workspace. */ export function classificationLabelTargetStubs( runFileId: string, stubs: ReadonlyArray, @@ -576,19 +533,14 @@ export function classificationLabelTargetStubs( ); } -/** Attempts to read a completed run's labels before giving up, and the backoff - * between them (delay × attempt). The import effect only re-runs when the run - * store changes, so a transient read failure has to be retried HERE: bailing - * out would leave the run unsettled and the file's "running" pill spinning - * until unrelated policy activity happened to nudge the effect. */ +/** Label-read attempts and backoff. Retried HERE because the import effect only re-runs on + * run-store changes, so bailing out would leave the file's "running" pill spinning. */ const LABEL_READ_ATTEMPTS = 3; const LABEL_READ_RETRY_MS = 2000; /** - * Read classification labels out of a completed run's output PDF. A 404 means - * that output aged out, so it's skipped; any other failure is transient and - * retried with backoff. Returns null when there are genuinely no labels to - * apply (including a run with no outputs), so the caller can settle the run. + * Read labels from a completed run's output PDF: a 404 means it aged out and is skipped, other + * failures retry with backoff. Null means no labels to apply, so the caller settles the run. */ async function readRunLabels(run: PolicyRunRecord): Promise { for (let attempt = 0; attempt < LABEL_READ_ATTEMPTS; attempt++) { @@ -609,21 +561,13 @@ async function readRunLabels(run: PolicyRunRecord): Promise { // Every output was read (or had aged out): there are no labels to apply. if (!transientFailure) return null; } - // Out of attempts. Settle the run unlabelled rather than spin forever; the - // file keeps its classification badge, just without tags. + // Out of attempts: settle unlabelled rather than spin forever - the badge stays, tags don't. return null; } /** - * Stamp `labels` onto the run's live descendants in place (workspace + storage) - * — no versioned child, no history entry, only tags. Returns the tagged ids. - * - * Runs twice, because `resolveTargets` reads a rendered snapshot of the - * workspace: a CONSUME_FILES that was dispatched but not yet rendered when the - * first pass ran leaves its target already gone by the time UPDATE_FILE_RECORD - * is processed, so that stamp no-ops and the labels would be silently lost. The - * second pass sees the forked leaf and tags it. Each id is stamped at most once - * across both passes, so the pass costs nothing when no consume raced. + * Stamp `labels` in place (workspace + storage) - tags only, no versioned child. Two passes + * because a consume racing the first would strand its target and lose the labels. */ async function stampClassificationLabels( labels: string[], @@ -634,10 +578,8 @@ async function stampClassificationLabels( const tagged = new Set(); for (let pass = 0; pass < 2; pass++) { - // Resolve and stamp the store in one synchronous block — no await between - // them, so a target can't be consumed in between. A consume AFTER the stamp - // is safe too: the CONSUME_FILES reducer carries classificationLabels onto - // the new leaf. + // Resolve and stamp synchronously so no consume lands in between; a consume after the + // stamp is safe, as the reducer carries the labels onto the new leaf. const fresh = resolveTargets().filter((s) => !tagged.has(s.id)); for (const stub of fresh) { tagged.add(stub.id); @@ -651,25 +593,20 @@ async function stampClassificationLabels( } if (mutated) ctx.bumpRevision(); - // Yield a macrotask so React processes this pass's stamps (and any consume - // that raced them) before the next pass re-resolves. + // Yield a macrotask so React processes this pass's stamps before the next re-resolves. if (pass === 0) await new Promise((resolve) => setTimeout(resolve)); } return Array.from(tagged); } -/** - * Deliver a classification run: read its labels and tag the live document with - * them. Metadata-only — nothing is versioned. - */ +/** Deliver a classification run: read its labels and tag the live document. Nothing is versioned. */ async function importClassificationLabels( run: PolicyRunRecord, resolveTargets: () => StirlingFileStub[], ctx: ClassificationImportContext, ): Promise { if (resolveTargets().length === 0) { - // The document left the workspace (closed, or a server-reconciled run with - // no local input link) — nothing to tag. + // The document left the workspace - nothing to tag. updateRun(run.runId, { imported: true }); return; } @@ -678,9 +615,8 @@ async function importClassificationLabels( labels && labels.length > 0 ? await stampClassificationLabels(labels, resolveTargets, ctx) : []; - // Settle either way so it stops re-importing. outputFileIds are the TAGGED - // workspace files (no forked version), so their policy badge persists. Safe - // to chain-key on: classification is always last, so nothing chains off it. + // Settle either way so it stops re-importing. outputFileIds are the TAGGED files, so their + // badge persists; safe to chain-key on, as classification is always last. updateRun(run.runId, { imported: true, importedFileIds: run.outputs.map((o) => o.fileId), @@ -689,16 +625,8 @@ async function importClassificationLabels( } /** - * Fetch a completed run's not-yet-imported output files and deliver them to the - * workspace. Per-output, via allSettled: each output is tracked once delivered, - * so a partial failure retries only the missing files on a later tick and the - * ones that succeeded are never added twice. `imported` flips true only once - * every output has landed. - * - * Delivery honours the policy's output mode: "new_version" replaces the input - * file with a versioned child (its history chain), "new_file" adds the output - * as a standalone file. Versioning falls back to a new file if the input is - * gone (no parent stub). + * Deliver a run's outputs per-output, so a partial failure retries only the missing files and + * successes are never added twice. Honours the output mode; versioning needs the parent stub. */ async function importOutputs( run: PolicyRunRecord, @@ -711,9 +639,8 @@ async function importOutputs( return; } - // Keep the input's original filename unless a rename rule is set — without a - // rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would - // otherwise rename every output. + // Keep the input's filename unless a rename rule is set, else the backend's auto-suffixed + // name renames every output. const targetName = ctx.outputName ? applyOutputName( run.fileName, @@ -957,7 +884,9 @@ async function runPolicyOnFile( await acquireDispatchSlot(priority); try { const target = resolvePolicyRunTarget(); - const runId = await runStoredPolicy(backendId, [file]); + // Recorded against a document this browser can resolve. One file per run, which is the only + // shape the server keeps a reference for. + const runId = await runStoredPolicy(backendId, [file], fileId); // recordRunStart marks this (policy, file) dispatched as it records the run. recordRunStart({ runId, diff --git a/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx index 89d648fcc5..a482b3fab1 100644 --- a/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx +++ b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx @@ -38,7 +38,7 @@ function generateSecurePassword() { const uint8Array = new Uint8Array(length); window.crypto.getRandomValues(uint8Array); // To avoid modulo bias, discard values >= 256 - (256 % charsetLength) - for (let i = 0; password.length < length; ) { + for (let i = 0; password.length < length;) { const randomByte = uint8Array[i]; i++; if (randomByte >= Math.floor(256 / charsetLength) * charsetLength) { diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx index 1b20297eec..3ff1aebf64 100644 --- a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx @@ -42,6 +42,7 @@ export function LoginLandingSetting() { return (
item.key === "general" - ? { ...item, component: } + ? { ...item, component: } : item, ); diff --git a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx index 1f04ea22ca..d5a2e7537d 100644 --- a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx +++ b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx @@ -8,7 +8,7 @@ import { usePolicyRuns, type PolicyRunRecord, } from "@app/components/policies/policyRunStore"; -import { isClassificationCategory } from "@app/data/policyCategories"; +import { isClassificationCategory } from "@app/data/classificationPolicy"; import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay"; type SignatureOverlayPassThrough = Pick< diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.test.ts b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts new file mode 100644 index 0000000000..d3cb55c043 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { + isClassificationCategory, + orderRewritesFirst, + policyDeliversOutputFiles, + policyRequiresAiEngine, + policyRewritesDocument, + shouldDispatchToAi, +} from "@app/data/classificationPolicy"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +const stub = ( + confidence?: StirlingFileStub["classificationConfidence"], +): StirlingFileStub => + ({ classificationConfidence: confidence }) as StirlingFileStub; + +describe("isClassificationCategory", () => { + it("recognises the classification category and nothing else", () => { + expect(isClassificationCategory("classification")).toBe(true); + expect(isClassificationCategory("security")).toBe(false); + expect(isClassificationCategory("")).toBe(false); + }); +}); + +describe("policy capabilities", () => { + it("treats classification as annotating, everything else as rewriting", () => { + expect(policyRewritesDocument("security")).toBe(true); + expect(policyRewritesDocument("classification")).toBe(false); + // A builder pipeline (no catalogue category) runs tools, so it rewrites. + expect(policyRewritesDocument("pipeline-abc123")).toBe(true); + }); + + it("expects output files from rewriting policies only", () => { + expect(policyDeliversOutputFiles("security")).toBe(true); + expect(policyDeliversOutputFiles("classification")).toBe(false); + }); + + it("marks classification as the AI-escalation policy", () => { + expect(policyRequiresAiEngine("classification")).toBe(true); + expect(policyRequiresAiEngine("security")).toBe(false); + }); +}); + +describe("orderRewritesFirst", () => { + it("moves annotating policies to the end, preserving other order", () => { + expect( + orderRewritesFirst(["classification", "security", "compliance"]), + ).toEqual(["security", "compliance", "classification"]); + }); + + it("leaves an order without an annotating policy untouched", () => { + expect(orderRewritesFirst(["security", "compliance"])).toEqual([ + "security", + "compliance", + ]); + }); + + it("is a no-op when the annotating policy is already last", () => { + expect(orderRewritesFirst(["security", "classification"])).toEqual([ + "security", + "classification", + ]); + }); + + it("handles the annotating policy as the only one", () => { + expect(orderRewritesFirst(["classification"])).toEqual(["classification"]); + }); +}); + +describe("shouldDispatchToAi", () => { + it("always dispatches a policy that is not classification", () => { + expect(shouldDispatchToAi("security", stub())).toBe(true); + expect(shouldDispatchToAi("security", stub("high"))).toBe(true); + }); + + it("holds back until the local heuristic has reported", () => { + // Not a skip: dispatching now races the local pass and pays for a free answer; + // the caller re-evaluates once the verdict lands. + expect(shouldDispatchToAi("classification", stub())).toBe(false); + }); + + it("lets a confident local verdict stand", () => { + expect(shouldDispatchToAi("classification", stub("high"))).toBe(false); + }); + + it("escalates anything less than confident", () => { + expect(shouldDispatchToAi("classification", stub("medium"))).toBe(true); + expect(shouldDispatchToAi("classification", stub("low"))).toBe(true); + expect(shouldDispatchToAi("classification", stub("none"))).toBe(true); + }); +}); diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.ts b/frontend/editor/src/proprietary/data/classificationPolicy.ts new file mode 100644 index 0000000000..4327b40d39 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationPolicy.ts @@ -0,0 +1,69 @@ +/** + * Everything specific to the built-in Classification policy, in one module. The generic policy + * runner asks the capability questions below instead of naming classification itself, so a second + * annotating policy needs a change here rather than in the runner. + * + * These are still keyed on the category id rather than a property each policy declares. That is + * deliberate for now: policies are becoming pipelines with labels behind a separate enforcement + * layer, which removes the category concept these would be declared against. Classification also + * stays genuinely privileged - it is the only policy with a browser-side implementation, so it can + * answer without the server. Ordering and output shape belong in that rework (an in-place output + * mode, and a run result that can carry findings as well as files), not in a flag added here first. + */ + +import type { + ClassificationConfidence, + StirlingFileStub, +} from "@app/types/fileContext"; + +/** Catalogue category id of the built-in Classification policy. */ +export const CLASSIFICATION_CATEGORY_ID = "classification"; + +export function isClassificationCategory(categoryId: string): boolean { + return categoryId === CLASSIFICATION_CATEGORY_ID; +} + +/** + * Whether the policy rewrites the document rather than only annotating it. Annotating policies are + * ordered last: a rewriting one after them would fork from the pre-annotation version. + */ +export function policyRewritesDocument(categoryId: string): boolean { + return !isClassificationCategory(categoryId); +} + +/** Whether a completed run is expected to deliver output files (annotators deliver labels). */ +export function policyDeliversOutputFiles(categoryId: string): boolean { + return policyRewritesDocument(categoryId); +} + +/** Whether the policy's server-side run exists only to escalate to the AI engine. */ +export function policyRequiresAiEngine(categoryId: string): boolean { + return isClassificationCategory(categoryId); +} + +/** Order annotating policies last; everything else keeps the order it was given. */ +export function orderRewritesFirst(categoryIds: string[]): string[] { + return [ + ...categoryIds.filter(policyRewritesDocument), + ...categoryIds.filter((id) => !policyRewritesDocument(id)), + ]; +} + +/** + * The one heuristic verdict trusted to stand on its own; anything less escalates to the AI, which + * overwrites it. Deliberately strict - a wrong label costs more than an engine call. + */ +const TRUSTED_CONFIDENCE: ClassificationConfidence = "high"; + +/** + * Whether the AI classifier should be asked about this file. Only once the heuristic has reported: + * dispatching before then races the first pass and bills for an answer it was about to produce. + */ +export function shouldDispatchToAi( + categoryId: string, + stub: StirlingFileStub, +): boolean { + if (!isClassificationCategory(categoryId)) return true; + const confidence = stub.classificationConfidence; + return confidence != null && confidence !== TRUSTED_CONFIDENCE; +} diff --git a/frontend/editor/src/proprietary/data/policyCategories.test.ts b/frontend/editor/src/proprietary/data/policyCategories.test.ts deleted file mode 100644 index 4b4fca2b7e..0000000000 --- a/frontend/editor/src/proprietary/data/policyCategories.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - isClassificationCategory, - pinClassificationLast, -} from "@app/data/policyCategories"; - -describe("isClassificationCategory", () => { - it("recognises the classification category and nothing else", () => { - expect(isClassificationCategory("classification")).toBe(true); - expect(isClassificationCategory("security")).toBe(false); - expect(isClassificationCategory("")).toBe(false); - }); -}); - -describe("pinClassificationLast", () => { - it("moves classification to the end, preserving other order", () => { - expect( - pinClassificationLast(["classification", "security", "compliance"]), - ).toEqual(["security", "compliance", "classification"]); - }); - - it("leaves an order without classification untouched", () => { - expect(pinClassificationLast(["security", "compliance"])).toEqual([ - "security", - "compliance", - ]); - }); - - it("is a no-op when classification is already last", () => { - expect(pinClassificationLast(["security", "classification"])).toEqual([ - "security", - "classification", - ]); - }); - - it("handles classification as the only policy", () => { - expect(pinClassificationLast(["classification"])).toEqual([ - "classification", - ]); - }); -}); diff --git a/frontend/editor/src/proprietary/data/policyCategories.ts b/frontend/editor/src/proprietary/data/policyCategories.ts deleted file mode 100644 index 820a93366b..0000000000 --- a/frontend/editor/src/proprietary/data/policyCategories.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** The classification policy's catalog category id. */ -export const CLASSIFICATION_CATEGORY_ID = "classification"; - -/** - * Classification is metadata-only: it runs async (never blocks), never forks a - * version, and always runs last. This predicate gates that special handling. - */ -export function isClassificationCategory(categoryId: string): boolean { - return categoryId === CLASSIFICATION_CATEGORY_ID; -} - -/** - * Move classification to the end of an execution order (others keep their order), - * so a persisted/displayed order can't place it anywhere but last. - */ -export function pinClassificationLast(orderedCategoryIds: string[]): string[] { - return [ - ...orderedCategoryIds.filter((id) => !isClassificationCategory(id)), - ...orderedCategoryIds.filter((id) => isClassificationCategory(id)), - ]; -} diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 389f98cd65..382a7f9222 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -35,7 +35,7 @@ import { removePolicy, } from "@app/services/policyBackend"; import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi"; -import { pinClassificationLast } from "@app/data/policyCategories"; +import { orderRewritesFirst } from "@app/data/classificationPolicy"; import type { PolicyToStore } from "@app/services/policyPipeline"; import type { PoliciesByCategory, @@ -327,9 +327,9 @@ export function usePolicies() { * first for an instant re-render; the next reconcile re-reads the server order. */ const reorderPolicies = useCallback((orderedCategoryIds: string[]) => { - // Pin classification last so the persisted/server order matches execution - // (it always runs last — see usePolicyAutoRun). - const ordered = pinClassificationLast(orderedCategoryIds); + // Annotating policies last, so the persisted order matches execution order + // (see usePolicyAutoRun). + const ordered = orderRewritesFirst(orderedCategoryIds); persistPolicyOrder(ordered); const current = loadPolicies(); const backendIds = ordered diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 52779b9cf8..85b2249cbb 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -4,7 +4,7 @@ import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; import { useAllFiles } from "@app/contexts/FileContext"; import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { policyAccentVar } from "@app/components/policies/policyStatus"; -import { isClassificationCategory } from "@app/data/policyCategories"; +import { isClassificationCategory } from "@app/data/classificationPolicy"; import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; /** Minimal provenance shape needed to resolve a file's inherited badges. */ diff --git a/frontend/editor/src/proprietary/services/heuristic/types.ts b/frontend/editor/src/proprietary/services/heuristic/types.ts index fbd8128ba4..5170feccd5 100644 --- a/frontend/editor/src/proprietary/services/heuristic/types.ts +++ b/frontend/editor/src/proprietary/services/heuristic/types.ts @@ -1,5 +1,7 @@ // Shared types for the client-side heuristic (non-AI) document classifier. +import type { ClassificationConfidence } from "@app/types/fileContext"; + /** Input document for the heuristic engine. */ export interface HeuristicDoc { fileName: string; @@ -11,7 +13,7 @@ export interface HeuristicDoc { } // "none" = no match or non-English; a real runtime value, not just a type state. -export type HeuristicConfidence = "none" | "low" | "medium" | "high"; +export type HeuristicConfidence = ClassificationConfidence; /** One scored candidate label with the rule hits that produced its score (debug only). */ export interface LabelScoreExplanation { diff --git a/frontend/editor/src/proprietary/services/policyApi.test.ts b/frontend/editor/src/proprietary/services/policyApi.test.ts new file mode 100644 index 0000000000..ae0b861e54 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyApi.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Only the document reference is pinned, being the one field with a rule attached: a filename here + * would reach a table that deliberately has nowhere to keep one. + */ + +const post = vi.fn().mockResolvedValue({ data: { jobId: "run-1" } }); + +vi.mock("@app/services/apiClient", () => ({ + default: { post: (...args: unknown[]) => post(...args) }, +})); + +const { runStoredPolicy } = await import("@app/services/policyApi"); + +/** The multipart body the last call sent. */ +function sentForm(): FormData { + return post.mock.calls.at(-1)?.[1] as FormData; +} + +const document = () => + new File(["%PDF-1.7"], "quarterly-report.pdf", { type: "application/pdf" }); + +describe("runStoredPolicy", () => { + beforeEach(() => { + post.mockClear(); + }); + + it("sends the workspace id of the document it is running on", async () => { + await runStoredPolicy("policy-1", [document()], "editor-file-1"); + + expect(post.mock.calls.at(-1)?.[0]).toBe("/api/v1/policies/policy-1/run"); + expect(sentForm().get("fileId")).toBe("editor-file-1"); + }); + + it("sends no reference when the caller has none", async () => { + // The export path can enforce on bytes with no workspace document behind them. + await runStoredPolicy("policy-1", [document()]); + + expect(sentForm().has("fileId")).toBe(false); + }); + + it("never sends the document's name as the reference", async () => { + await runStoredPolicy("policy-1", [document()], "editor-file-1"); + + expect(sentForm().get("fileId")).not.toContain("quarterly-report"); + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index b0d78f36f7..d93e51fa82 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -60,13 +60,18 @@ export async function reorderPolicies(orderedIds: string[]): Promise { await apiClient.put("/api/v1/policies/order", orderedIds); } -/** Run a stored policy by id on the supplied files; returns the run id. */ +/** + * Run a stored policy by id; returns the run id. `fileId` is this workspace's own opaque id, recorded + * against any failure of the run. Only honoured for a single-document run, and never a filename. + */ export async function runStoredPolicy( id: string, files: File[], + fileId?: string, ): Promise { const form = new FormData(); for (const file of files) form.append("fileInput", file); + if (fileId) form.append("fileId", fileId); // Don't set Content-Type: the HTTP client must generate multipart/form-data // WITH its boundary from the FormData body. A manual boundary-less header makes // the server reject the request ("no multipart boundary parameter"). diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index cfd8aef1b4..ab93f734fb 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -1,13 +1,16 @@ import React, { useCallback, useMemo, useState, useEffect } from "react"; -import { Modal, Text } from "@mantine/core"; +import { Group, Modal, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; -import { useMediaQuery } from "@mantine/hooks"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { useLocation } from "react-router-dom"; import { useAuth } from "@app/auth/UseSession"; import { isUserAnonymous } from "@app/auth/supabase"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; +import { SettingsMobileBackButton } from "@app/components/shared/config/SettingsMobileBackButton"; +import { SettingsMobileNavHeader } from "@app/components/shared/config/SettingsMobileNavHeader"; +import { SettingsNavChevron } from "@app/components/shared/config/SettingsNavChevron"; import Overview from "@app/components/shared/config/configSections/Overview"; import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections"; import { consumePendingSettingsNav } from "@app/utils/appSettings"; @@ -46,12 +49,13 @@ const AppConfigModal: React.FC = ({ extraSections, hiddenSectionKeys, }) => { - const isMobile = useMediaQuery("(max-width: 1024px)"); + const isMobile = useIsMobile(); const { signOut, user } = useAuth(); const { t } = useTranslation(); const [confirmOpen, setConfirmOpen] = useState(false); const [active, setActive] = useState("overview"); + const [mobilePane, setMobilePane] = useState<"nav" | "content">("nav"); const [notice, setNotice] = useState(null); const location = useLocation(); @@ -60,7 +64,10 @@ const AppConfigModal: React.FC = ({ // Consume any section stashed by openAppSettings on mount to land on it. useEffect(() => { const pending = consumePendingSettingsNav(); - if (pending) setActive(pending); + if (pending) { + setActive(pending); + setMobilePane("content"); + } }, []); // Check if user can access billing features (non-anonymous users only) @@ -70,6 +77,7 @@ const AppConfigModal: React.FC = ({ const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined; if (detail?.key) { setActive(detail.key); + setMobilePane("content"); } }; window.addEventListener("appConfig:navigate", handler as EventListener); @@ -90,6 +98,7 @@ const AppConfigModal: React.FC = ({ if (!opened) return; if (initialSection) { setActive(initialSection); + setMobilePane("content"); return; } const match = stripBasePath(location.pathname).match( @@ -97,6 +106,9 @@ const AppConfigModal: React.FC = ({ ); if (match) { setActive(match[1] as NavKey); + setMobilePane("content"); + } else { + setMobilePane("nav"); } }, [opened, initialSection, location.pathname]); @@ -225,34 +237,43 @@ const AppConfigModal: React.FC = ({ className={`modal-nav ${isMobile ? "mobile" : ""}`} style={{ background: colors.navBg, - borderRight: `1px solid ${colors.headerBorder}`, + ...(isMobile + ? { display: mobilePane === "nav" ? undefined : "none" } + : { borderRight: `1px solid ${colors.headerBorder}` }), }} > +
{configNavSections.map((section) => (
- {!isMobile && ( - - {section.title} - - )} + + {section.title} +
{section.items.map((item) => { const isActive = active === item.key; const color = isActive ? colors.navItemActive : colors.navItem; - const iconSize = isMobile ? 28 : 18; + const iconSize = 18; return (
setActive(item.key)} + onClick={() => { + setActive(item.key); + setMobilePane("content"); + }} className={`modal-nav-item ${isMobile ? "mobile" : ""}`} style={{ background: isActive @@ -264,13 +285,17 @@ const AppConfigModal: React.FC = ({ icon={item.icon} width={iconSize} height={iconSize} - style={{ color }} + style={{ color, flexShrink: 0 }} /> - {!isMobile && ( - - {item.label} - - )} + + {item.label} + +
); })} @@ -281,7 +306,14 @@ const AppConfigModal: React.FC = ({
{/* Right content */} -
+
{/* Sticky header with section title and small close button */}
= ({ borderBottom: `1px solid ${colors.headerBorder}`, }} > - - {activeLabel} - {active === "plan" && notice ? ( - - – {notice} - - ) : null} - + + setMobilePane("nav")} + /> + + {activeLabel} + {active === "plan" && notice ? ( + + – {notice} + + ) : null} + + =16.8.0", "react-dom": ">=16.8.0", @@ -991,15 +971,15 @@ } }, "node_modules/@embedpdf/plugin-attachment": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.14.4.tgz", - "integrity": "sha512-ktcv48w5DQVG7LTTt45rsth5X4dyxaXZb5zS1bElF1ULp6g7SRy4a0s3UWBY/UDKbpVtrby3JTsfs5fMU2VaRg==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.15.0.tgz", + "integrity": "sha512-vv8uzyqNSujrXKsmIKyV9D/8UnWfyo/MLY5lSQFoFy8Egecbqlbe8LTFcyHYA3fNhvKds+JFLV1khxnnp6tBoA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1008,15 +988,15 @@ } }, "node_modules/@embedpdf/plugin-bookmark": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.14.4.tgz", - "integrity": "sha512-SfFhVsqEA+5rJUQSVTP259+cgMqbWR93G/kK6GhDcXou4jNGKNfmiWLeWSMp8lsnGhbMkWxFYJKLuH44NEm63g==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.15.0.tgz", + "integrity": "sha512-pioAUfmKWiM0uc1PkGL3um7SPpcSrtcNhzdV6sgKDtLYxNBzObaCRbsQzGr0vFRayMNkDIlsRsx2yiLK6D+JcQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1025,15 +1005,15 @@ } }, "node_modules/@embedpdf/plugin-document-manager": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.14.4.tgz", - "integrity": "sha512-1qUKu/RAN9LV+Ih7geig0X/2i4jIn8P1D0I0nQUwSMM9hJmitcyDJ/9SK/eOSsdEs4SulIqt+a2W2IMZOY2Vew==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.15.0.tgz", + "integrity": "sha512-M8EwOuonICSHfOklTMwk0XfyPhG9v9EMDRU9Pvz0zAe1DJzUPtIxUh+CgxtFSSnUeKSfxE7JpTRryrbUW0XcGg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1042,15 +1022,15 @@ } }, "node_modules/@embedpdf/plugin-export": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.14.4.tgz", - "integrity": "sha512-z1IGsGzWve+g81KCrNbZYN/jHkb9mImodVhTfL35mJfOjOahRmbN0OUn1TvcNHaiXKjy8xcfabvxHDnSaViGlA==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.15.0.tgz", + "integrity": "sha512-GFmC4nkZZnH1swivfSwLako2YV6d60QPSPUqvozQaMWDuE8rNq/ElYISVihkEAxbhI4h3q5AkhDKyCVFeJJVbg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1059,15 +1039,15 @@ } }, "node_modules/@embedpdf/plugin-history": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.14.4.tgz", - "integrity": "sha512-X6SBfpyKgQTHNmawoZOfeTW/nhE3NUeqd+gPLFCUkeVTaYO31qSeje3Z7z7pm17dMp5ar9TsgT69ajn6zlsG1w==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.15.0.tgz", + "integrity": "sha512-3Dk4Atc5vdHsTjC8tJ8MOGPcUJLkCWbMzt3sTllmyNc534qTZtUvFHDeaaB65u50EJGjFRpUJ6Xcnp9A81lMdg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1076,15 +1056,15 @@ } }, "node_modules/@embedpdf/plugin-interaction-manager": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.14.4.tgz", - "integrity": "sha512-y4MeHBy5s/SR5ma4rezYADIUujViAZSQe8A/CKehve4eU6Z9YF2AflL/M6KG24gCsUyW4D04AxAn3bvsXLR3nw==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.15.0.tgz", + "integrity": "sha512-YCMvTvu4Fm1KNuEhj+CftzG+T6F1+/QhI7eaYft9Lp5xm3CYSXdR3pNfGwNFy5XkDUDwB1IscLTiU0q75viMCw==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1093,17 +1073,17 @@ } }, "node_modules/@embedpdf/plugin-pan": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.14.4.tgz", - "integrity": "sha512-TAVGzXg9qgfiZHZfZFkHry9jCSQjk/borV1DB8HJTcgTk52phhaSr4EFjBZ808Hj5t+cc9VZWoE1JhL8AAiKfw==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.15.0.tgz", + "integrity": "sha512-bgFM7xTJRNzkFs5CH9apv5USR7Db2fdbNVbHq/U1GanEyz/71gZGoDWUN9FF/FbNvBfSfvPYZDv9DLJYLbeoBQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", - "@embedpdf/plugin-interaction-manager": "2.14.4", - "@embedpdf/plugin-viewport": "2.14.4", + "@embedpdf/core": "2.15.0", + "@embedpdf/plugin-interaction-manager": "2.15.0", + "@embedpdf/plugin-viewport": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1112,15 +1092,15 @@ } }, "node_modules/@embedpdf/plugin-print": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.14.4.tgz", - "integrity": "sha512-qk1aQhLS+8eBL6lcT46p+fZaNjvLMVQF2Nl/t78aOy+cNskXHeYsnZi9tJKNYGLHbi+8+Dg2iHVIhFvweFn/Ww==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.15.0.tgz", + "integrity": "sha512-IS6Wvi7zY0iozdYmVwfZXSjKURBe4AqhuyV1QtbcUS1hZxe0ASRETGiHe9ONpuokWemZmYDHNyTrdF7+5hdLEw==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=18.0.0", "react-dom": ">=18.0.0", @@ -1129,20 +1109,20 @@ } }, "node_modules/@embedpdf/plugin-redaction": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.14.4.tgz", - "integrity": "sha512-pECosjyBaLlYz1Fgde4gqquV4uQY8LAkbx7TtMwP1EhBFtG0QeoeuhytoLm9I/cWPi84jfaQjkWZB58HPxR5Zw==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.15.0.tgz", + "integrity": "sha512-H4WgRSSqBlWtP7sgDbAqWW/w6HAyjA3RS6lMuEmCMF19trDyQMdeAC8ZyQkFCYudFk9L+ydradSbNBYK18PfDw==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4", - "@embedpdf/utils": "2.14.4" + "@embedpdf/models": "2.15.0", + "@embedpdf/utils": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", - "@embedpdf/plugin-annotation": "2.14.4", - "@embedpdf/plugin-history": "2.14.4", - "@embedpdf/plugin-interaction-manager": "2.14.4", - "@embedpdf/plugin-selection": "2.14.4", + "@embedpdf/core": "2.15.0", + "@embedpdf/plugin-annotation": "2.15.0", + "@embedpdf/plugin-history": "2.15.0", + "@embedpdf/plugin-interaction-manager": "2.15.0", + "@embedpdf/plugin-selection": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1151,15 +1131,15 @@ } }, "node_modules/@embedpdf/plugin-render": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.14.4.tgz", - "integrity": "sha512-KNY3EGYf9SRqtDBOEO9mR3Hyv9mN8TaC3ztERj0BX8zra7mkjkQnhGQlMxif1dGOoTD9XIcPqJvaw8o7PWDJDw==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.15.0.tgz", + "integrity": "sha512-EVfn8XsdU10VgrSs9qKo8nqjfUyt2/NWFJtlW3nX4sZ74Pi9xSvEa7/B99/LYZZMa0ENzi+4HKAXiQ6NUXisDg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1168,15 +1148,15 @@ } }, "node_modules/@embedpdf/plugin-rotate": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.14.4.tgz", - "integrity": "sha512-+bBBHfoSMBMjHubDgPkCCu3o2NZn5vv8l/2gmzH6D8k90UVLB2AOei9VTmfy5xqoit+eoY6iSl7w3qyijNfB0A==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.15.0.tgz", + "integrity": "sha512-mhfDC8NCQ8H6ati3O6smA6ZxZoMgPGtloEeY6gs61+zoxPKyXiuCpxZKJctRfYdrTdoRgXpFa3fKflwStMcgFg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1185,16 +1165,16 @@ } }, "node_modules/@embedpdf/plugin-scroll": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.14.4.tgz", - "integrity": "sha512-0JgPwnQqB9THKJt/mOubgFkiTcCkOI6kFCZz8zlwl88DAJcUbdhJoTwCJZ2STYSfECTLB4ZFjb5fZ9AXyfkf0g==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.15.0.tgz", + "integrity": "sha512-UqjLH3lafrpqN1qcxIpfd1MfWW5Fdf6/4LjneUabHt+3gxy1qd2KU9oPR5wwinsh3vmjn90qURsnqIHL5jnaOg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", - "@embedpdf/plugin-viewport": "2.14.4", + "@embedpdf/core": "2.15.0", + "@embedpdf/plugin-viewport": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1203,15 +1183,15 @@ } }, "node_modules/@embedpdf/plugin-search": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.14.4.tgz", - "integrity": "sha512-ErRlg0MKLxZ0c4OKk5RjPOV8f8yCaqFi6zqUxBI49/3yN6wDINKvm8dxt5Q2QGUa740n/XylT5BTbjH6lAjCzQ==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.15.0.tgz", + "integrity": "sha512-mMzy8uo3xvDMjSc+xuBMgdI/mmmpgbZ6xKTmNxpF0d8BcaWu6Zp9a4ewamfjBwg8WF/qUW49U6HYZsiRr3NUEQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1220,17 +1200,17 @@ } }, "node_modules/@embedpdf/plugin-selection": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.14.4.tgz", - "integrity": "sha512-8w6ZUdwRcBhNCQjlS8mFvsMXI/wr1Q7DljwlFmKaEQyM9vXm2rI9WGN0z9ffjKHlsYn0yg98pqyPLtTevH1P7Q==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.15.0.tgz", + "integrity": "sha512-iEnhx0jeQrbze7WHkHhZtF21yfGBuqF2B+V2efLHT2O0a2fnof+PEVaQQRgERSfrwRvLvyEJL9ud54E41ahsRQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4", - "@embedpdf/utils": "2.14.4" + "@embedpdf/models": "2.15.0", + "@embedpdf/utils": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", - "@embedpdf/plugin-interaction-manager": "2.14.4", + "@embedpdf/core": "2.15.0", + "@embedpdf/plugin-interaction-manager": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1239,15 +1219,15 @@ } }, "node_modules/@embedpdf/plugin-spread": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.14.4.tgz", - "integrity": "sha512-ybJNbOMXBciD9cS+wwAUM1L5ZZMgF0+8Tpi78MaXGwnwH1wJUSx1Tx90YNsO9DxIKKckDsxUQREwUtTTcwFLEA==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.15.0.tgz", + "integrity": "sha512-Gj1Nl9E/T92MkaBxL+BCH8WHfzvfM+Vb9vgTPi0gnV7Jr1SaWrtIQpMlYom21f4p9pT/m7KuBVz3xA+NV8r39A==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1256,16 +1236,16 @@ } }, "node_modules/@embedpdf/plugin-thumbnail": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.14.4.tgz", - "integrity": "sha512-80D+R2uXoTsmQXFpGGhtapv3I45WNzTSIkcD3VvLywjS2MHwgCNY51RQsn5bc3HINZW9d/SX1F39qiv4HzPp6A==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.15.0.tgz", + "integrity": "sha512-39EojqobHmvSvk2ejKeh58m43ApGS3cie/sVZP+pkXr6MRogX6+ivMa5GfoqBzh9wdZAZ3l4XTd+RQ4kFZ4aqQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", - "@embedpdf/plugin-render": "2.14.4", + "@embedpdf/core": "2.15.0", + "@embedpdf/plugin-render": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1274,18 +1254,18 @@ } }, "node_modules/@embedpdf/plugin-tiling": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.14.4.tgz", - "integrity": "sha512-++1bzvU0Xqi6a1XeUOLAHU7D/FVUNobC/behSSn3WZnY/wIpYG3dXs5NbhNzeomIGXYHC4jCXbKGBPe5TNdoSw==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.15.0.tgz", + "integrity": "sha512-zBjKCToAkL1tYvkIjFCjsbfUzZ0QBPczL3wRy/IdNZ9CQuZrqqqp3xEFrI4Nz2o9XQd9heBlMA9Zo/yieuazqA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", - "@embedpdf/plugin-render": "2.14.4", - "@embedpdf/plugin-scroll": "2.14.4", - "@embedpdf/plugin-viewport": "2.14.4", + "@embedpdf/core": "2.15.0", + "@embedpdf/plugin-render": "2.15.0", + "@embedpdf/plugin-scroll": "2.15.0", + "@embedpdf/plugin-viewport": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1294,15 +1274,15 @@ } }, "node_modules/@embedpdf/plugin-viewport": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.14.4.tgz", - "integrity": "sha512-ZhbDITPZ/h1Vd9cUbCPShSbH5RXd/biCw9w6fneTItruMauYIcTNUS2eWLJC0Yovhw7nu0TjRVYuTQ71jzs0dg==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.15.0.tgz", + "integrity": "sha512-MXNBQOitr1cAoCoBKwAR3O78eLtHBbIngOkWqgm6EpWTdoYDBVhZlvVcmwmjheZKjMJFnU3sFr98w6Qwix3Cjg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", + "@embedpdf/core": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1311,17 +1291,17 @@ } }, "node_modules/@embedpdf/plugin-zoom": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.14.4.tgz", - "integrity": "sha512-a5kUjbG9sKMyO8qqDTrtL30VJ3dSEyNHHvszhfbvKtv4hppz/fSKS78vieu3lw48K11BoioImzCAF6Y3R0LMiw==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.15.0.tgz", + "integrity": "sha512-d+YhwqcQNZaaHczVGe4pbaAYQXWO4MEoivcW7jOpE31VnKqEAdjCMye8F0ctcF5OjgbXeb6AKCk4L4fDdLF4mg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "2.14.4" + "@embedpdf/models": "2.15.0" }, "peerDependencies": { - "@embedpdf/core": "2.14.4", - "@embedpdf/plugin-scroll": "2.14.4", - "@embedpdf/plugin-viewport": "2.14.4", + "@embedpdf/core": "2.15.0", + "@embedpdf/plugin-scroll": "2.15.0", + "@embedpdf/plugin-viewport": "2.15.0", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -1330,9 +1310,9 @@ } }, "node_modules/@embedpdf/utils": { - "version": "2.14.4", - "resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.14.4.tgz", - "integrity": "sha512-W7BZSNAQeUPQhi6l96Q4oFtg8Vgbt622LQL5ql0oZjyJNJRbYqCJIiJpR+5ZvK6S/x2MadZnYSzBJ/ekL6394A==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.15.0.tgz", + "integrity": "sha512-13UEMPpu5XrxmYI/MPiLtJC3R3b1g8ii3zfhQ3g1WpECybnwTuhBgwqaOvqw3rVCKCnXEgNMs4PBCzLyFpZZTw==", "license": "MIT", "peerDependencies": { "preact": "^10.26.4", @@ -2925,6 +2905,329 @@ "dev": true, "license": "MIT" }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz", + "integrity": "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz", + "integrity": "sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz", + "integrity": "sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz", + "integrity": "sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz", + "integrity": "sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz", + "integrity": "sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz", + "integrity": "sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz", + "integrity": "sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz", + "integrity": "sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz", + "integrity": "sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz", + "integrity": "sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz", + "integrity": "sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz", + "integrity": "sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz", + "integrity": "sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz", + "integrity": "sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz", + "integrity": "sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz", + "integrity": "sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz", + "integrity": "sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz", + "integrity": "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxlint/binding-android-arm-eabi": { "version": "1.77.0", "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz", @@ -3866,12 +4169,12 @@ "license": "MIT" }, "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-8.1.0.tgz", + "integrity": "sha512-2SX/1jW6CIMAiebvVv5ZInoCEuWQmMyBoJXXGC6Vjakjp/fpxP5eHs7/V6WKuPEIbuK06+VpjH+vjLQhr98rDQ==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/sindresorhus/is?sponsor=1" @@ -7510,15 +7813,15 @@ } }, "node_modules/cacheable-request": { - "version": "13.0.18", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.18.tgz", - "integrity": "sha512-rFWadDRKJs3s2eYdXlGggnBZKG7MTblkFBB0YllFds+UYnfogDp2wcR6JN97FhRkHTvq59n2vhNoHNZn29dh/Q==", + "version": "13.0.19", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.19.tgz", + "integrity": "sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==", "license": "MIT", "dependencies": { - "@types/http-cache-semantics": "^4.0.4", + "@types/http-cache-semantics": "^4.2.0", "get-stream": "^9.0.1", "http-cache-semantics": "^4.2.0", - "keyv": "^5.5.5", + "keyv": "^5.6.0", "mimic-response": "^4.0.0", "normalize-url": "^8.1.1", "responselike": "^4.0.2" @@ -7527,25 +7830,6 @@ "node": ">=18" } }, - "node_modules/cacheable-request/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/cacheable/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -7733,6 +8017,18 @@ "devtools-protocol": "*" } }, + "node_modules/chunk-data": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chunk-data/-/chunk-data-0.1.0.tgz", + "integrity": "sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -9438,15 +9734,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", - "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==", - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -9779,38 +10066,44 @@ } }, "node_modules/got": { - "version": "14.6.6", - "resolved": "https://registry.npmjs.org/got/-/got-14.6.6.tgz", - "integrity": "sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/got/-/got-15.1.0.tgz", + "integrity": "sha512-DG+DAAkRtno+oDr/GBsliAkhN9+zczOPM5qXk3efDZY3qvyRnZU+NwQlb/IOY6cSnxxTR9z3aHCcShJyjb0hJA==", "license": "MIT", "dependencies": { - "@sindresorhus/is": "^7.0.1", + "@sindresorhus/is": "^8.0.0", "byte-counter": "^0.1.0", "cacheable-lookup": "^7.0.0", - "cacheable-request": "^13.0.12", + "cacheable-request": "^13.0.18", + "chunk-data": "^0.1.0", "decompress-response": "^10.0.0", - "form-data-encoder": "^4.0.2", "http2-wrapper": "^2.2.1", - "keyv": "^5.5.3", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^4.0.1", + "keyv": "^5.6.0", + "lowercase-keys": "^4.0.1", "responselike": "^4.0.2", - "type-fest": "^4.26.1" + "type-fest": "^5.6.0", + "uint8array-extras": "^1.5.0" }, "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "url": "https://github.com/sindresorhus/got?sponsor=1" } }, - "node_modules/got/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "license": "MIT", + "node_modules/got/node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", "dependencies": { - "@keyv/serialize": "^1.1.1" + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/graceful-fs": { @@ -10015,12 +10308,12 @@ "license": "MIT" }, "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", "license": "MIT", - "dependencies": { - "void-elements": "3.1.0" + "funding": { + "url": "https://locize.com" } }, "node_modules/html-tags": { @@ -10094,9 +10387,9 @@ } }, "node_modules/i18next": { - "version": "25.10.10", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", - "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", "funding": [ { "type": "individual", @@ -10112,11 +10405,8 @@ } ], "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.2" - }, "peerDependencies": { - "typescript": "^5 || ^6" + "typescript": "^5 || ^6 || ^7" }, "peerDependenciesMeta": { "typescript": { @@ -10236,12 +10526,12 @@ "license": "ISC" }, "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/inline-style-parser": { @@ -10756,6 +11046,15 @@ "setimmediate": "^1.0.5" } }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -10897,19 +11196,19 @@ } }, "node_modules/license-report": { - "version": "6.8.2", - "resolved": "https://registry.npmjs.org/license-report/-/license-report-6.8.2.tgz", - "integrity": "sha512-eWzJujDhPm5bKTrolTBt8mvL6YW3c5SY1kpqnt7GmTLU01rOtzjqe3sevOQLF2dPY7dV+VnSZIWV774Cgqz/Eg==", + "version": "6.8.5", + "resolved": "https://registry.npmjs.org/license-report/-/license-report-6.8.5.tgz", + "integrity": "sha512-mUOQD1C1yEHShuPeZBjv7y/EXn7kYEfq7roY1lQmVtYm4FCE81aMn65gGrtU7Cw+YRuwchkoMYegoF3wdeZNLQ==", "license": "MIT", "dependencies": { "@kessler/tableify": "^1.0.2", "debug": "^4.4.3", "eol": "^0.10.0", "find-up-simple": "^1.0.1", - "got": "^14.6.6", - "ini": "^6.0.0", + "got": "^15.0.5", + "ini": "^7.0.0", "rc": "^1.2.8", - "semver": "^7.7.4", + "semver": "^7.8.0", "tablemark": "^4.1.0", "text-table": "^0.2.0", "visit-values": "^2.0.0" @@ -11249,12 +11548,12 @@ "license": "MIT" }, "node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-4.0.1.tgz", + "integrity": "sha512-wI9Nui/L8VfADa/cr/7NQruaASk1k23/Uh1khQ02BCVYiiy8F4AhOGnQzJy3Fl/c44GnYSbZHv8g7EcG3kJ1Qg==", "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12635,6 +12934,68 @@ "dev": true, "license": "MIT" }, + "node_modules/oxfmt": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.62.0.tgz", + "integrity": "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.62.0", + "@oxfmt/binding-android-arm64": "0.62.0", + "@oxfmt/binding-darwin-arm64": "0.62.0", + "@oxfmt/binding-darwin-x64": "0.62.0", + "@oxfmt/binding-freebsd-x64": "0.62.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", + "@oxfmt/binding-linux-arm64-gnu": "0.62.0", + "@oxfmt/binding-linux-arm64-musl": "0.62.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", + "@oxfmt/binding-linux-riscv64-musl": "0.62.0", + "@oxfmt/binding-linux-s390x-gnu": "0.62.0", + "@oxfmt/binding-linux-x64-gnu": "0.62.0", + "@oxfmt/binding-linux-x64-musl": "0.62.0", + "@oxfmt/binding-openharmony-arm64": "0.62.0", + "@oxfmt/binding-win32-arm64-msvc": "0.62.0", + "@oxfmt/binding-win32-ia32-msvc": "0.62.0", + "@oxfmt/binding-win32-x64-msvc": "0.62.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxfmt/node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/oxlint": { "version": "1.77.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", @@ -12684,15 +13045,6 @@ } } }, - "node_modules/p-cancelable": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", - "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, "node_modules/p-map": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", @@ -13846,19 +14198,19 @@ } }, "node_modules/react-i18next": { - "version": "16.6.6", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz", - "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==", + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.2", - "html-parse-stringify": "^3.0.1", + "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "i18next": ">= 25.10.9", + "i18next": ">= 26.2.0", "react": ">= 16.8.0", - "typescript": "^5 || ^6" + "typescript": "^5 || ^6 || ^7" }, "peerDependenciesMeta": { "react-dom": { @@ -14500,6 +14852,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/responselike/node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rettime": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", @@ -14803,9 +15167,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -16308,7 +16672,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -16717,6 +17080,18 @@ "tsc6": "bin/tsc6" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -17462,15 +17837,6 @@ "dev": true, "license": "MIT" }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/vue": { "version": "3.5.39", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", diff --git a/frontend/package.json b/frontend/package.json index f874dedfa3..71a71661e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,29 +9,29 @@ "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", "@cantoo/pdf-lib": "^2.8.2", "@dnd-kit/core": "^6.3.1", - "@embedpdf/core": "^2.14.4", + "@embedpdf/core": "^2.15.0", "@embedpdf/engines": "^2.14.4", - "@embedpdf/models": "^2.14.4", - "@embedpdf/plugin-annotation": "^2.14.4", - "@embedpdf/plugin-attachment": "^2.14.4", - "@embedpdf/plugin-bookmark": "^2.14.4", - "@embedpdf/plugin-document-manager": "^2.14.4", - "@embedpdf/plugin-export": "^2.14.4", - "@embedpdf/plugin-history": "^2.14.4", - "@embedpdf/plugin-interaction-manager": "^2.14.4", - "@embedpdf/plugin-pan": "^2.14.4", - "@embedpdf/plugin-print": "^2.14.4", - "@embedpdf/plugin-redaction": "^2.14.4", - "@embedpdf/plugin-render": "^2.14.4", - "@embedpdf/plugin-rotate": "^2.14.4", - "@embedpdf/plugin-scroll": "^2.14.4", - "@embedpdf/plugin-search": "^2.14.4", + "@embedpdf/models": "^2.15.0", + "@embedpdf/plugin-annotation": "^2.15.0", + "@embedpdf/plugin-attachment": "^2.15.0", + "@embedpdf/plugin-bookmark": "^2.15.0", + "@embedpdf/plugin-document-manager": "^2.15.0", + "@embedpdf/plugin-export": "^2.15.0", + "@embedpdf/plugin-history": "^2.15.0", + "@embedpdf/plugin-interaction-manager": "^2.15.0", + "@embedpdf/plugin-pan": "^2.15.0", + "@embedpdf/plugin-print": "^2.15.0", + "@embedpdf/plugin-redaction": "^2.15.0", + "@embedpdf/plugin-render": "^2.15.0", + "@embedpdf/plugin-rotate": "^2.15.0", + "@embedpdf/plugin-scroll": "^2.15.0", + "@embedpdf/plugin-search": "^2.15.0", "@embedpdf/plugin-selection": "^2.14.4", - "@embedpdf/plugin-spread": "^2.14.4", - "@embedpdf/plugin-thumbnail": "^2.14.4", - "@embedpdf/plugin-tiling": "^2.14.4", - "@embedpdf/plugin-viewport": "^2.14.4", - "@embedpdf/plugin-zoom": "^2.14.4", + "@embedpdf/plugin-spread": "^2.15.0", + "@embedpdf/plugin-thumbnail": "^2.15.0", + "@embedpdf/plugin-tiling": "^2.15.0", + "@embedpdf/plugin-viewport": "^2.15.0", + "@embedpdf/plugin-zoom": "^2.15.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mantine/core": "^8.3.1", @@ -59,10 +59,10 @@ "autoprefixer": "^10.4.21", "axios": "^1.15.0", "d3": "^7.9.0", - "i18next": "^25.10.10", - "i18next-browser-languagedetector": "^8.2.0", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "jszip": "^3.10.1", - "license-report": "^6.8.0", + "license-report": "^6.8.5", "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", "pixelmatch": "^7.1.0", @@ -71,7 +71,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-easy-crop": "^5.5.6", - "react-i18next": "^16.6.6", + "react-i18next": "^17.0.11", "react-markdown": "^9.0.3", "react-rnd": "^10.5.2", "react-router-dom": "^7.9.1", @@ -143,12 +143,12 @@ "license-checker": "^25.0.1", "msw": "^2.14.6", "msw-storybook-addon": "^2.0.7", + "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", - "prettier": "^3.8.1", "puppeteer": "^24.25.0", "rollup-plugin-visualizer": "^7.0.1", "storybook": "^9.1.20", diff --git a/frontend/stylelint.config.mjs b/frontend/stylelint.config.mjs index 2781e78516..79f3108c9c 100644 --- a/frontend/stylelint.config.mjs +++ b/frontend/stylelint.config.mjs @@ -1,4 +1,4 @@ -// Deliberately minimal: Prettier owns formatting and `task frontend:lint:colors` +// Deliberately minimal: oxfmt owns formatting and `task frontend:lint:colors` // owns the theme tokens, so this only carries rules that catch real bugs. export default { ignoreFiles: [ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index b1b8ef56b4..eddabd2eef 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a9db11550c..ad7845be30 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/scripts/init-without-ocr.sh b/scripts/init-without-ocr.sh index 8dc1040e61..c220cf1137 100755 --- a/scripts/init-without-ocr.sh +++ b/scripts/init-without-ocr.sh @@ -890,6 +890,11 @@ log "Setting permissions..." mkdir -p /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps /logs /configs /configs/heap_dumps /configs/cache /customFiles /pipeline /storage || true CHOWN_PATHS=("$HOME" "/logs" "/scripts" "/configs" "/customFiles" "/pipeline" "/storage" "/tmp/stirling-pdf" "/app.jar") [ -d /usr/share/fonts/truetype ] && CHOWN_PATHS+=("/usr/share/fonts/truetype") +# Chowned here rather than at build time so it follows PUID/PGID remapping. +if [ -d "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}" ]; then + mkdir -p "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data" || true + CHOWN_PATHS+=("${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data") +fi CHOWN_OK=true for p in "${CHOWN_PATHS[@]}"; do if [ -e "$p" ]; then @@ -961,6 +966,38 @@ else exit 1 fi +# ---------- AI engine ---------- +# Only the fat image ships it. The backend already defaults to http://localhost:5001. +STIRLING_ENGINE_HOME="${STIRLING_ENGINE_HOME:-/opt/stirling-engine}" +ENGINE_PID="" +if [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ] && [ "${AIENGINE_ENABLED:-true}" = "false" ]; then + log "AI engine bundled but AIENGINE_ENABLED=false; not starting it." +elif [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ]; then + log "Starting bundled AI engine on port ${STIRLING_ENGINE_PORT:-5001}..." + ENGINE_CMD=( + "$STIRLING_ENGINE_HOME/.venv/bin/python" -m uvicorn + stirling.api.app:app + --host 127.0.0.1 + --port "${STIRLING_ENGINE_PORT:-5001}" + --workers "${STIRLING_ENGINE_WORKERS:-2}" + --app-dir "$STIRLING_ENGINE_HOME/src" + ) + # init.sh exports PYTHONPATH for unoserver's 3.12 venv; inheriting it breaks the 3.13 engine. + if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then + env -u PYTHONPATH "${ENGINE_CMD[@]}" & + elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then + env -u PYTHONPATH \ + HOME="$(getent passwd "$RUNTIME_USER" | cut -d: -f6)" \ + USER="$RUNTIME_USER" \ + LOGNAME="$RUNTIME_USER" \ + setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "${ENGINE_CMD[@]}" & + else + env -u PYTHONPATH "${ENGINE_CMD[@]}" & + fi + ENGINE_PID=$! + log "AI engine started (PID $ENGINE_PID)" +fi + if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then "${JAVA_CMD[@]}" & elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then @@ -1069,6 +1106,12 @@ fi wait "$JAVA_PID" || true exit_code=$? + +if [ -n "$ENGINE_PID" ] && kill -0 "$ENGINE_PID" 2>/dev/null; then + log "Stopping AI engine (PID $ENGINE_PID)..." + kill "$ENGINE_PID" 2>/dev/null || true + wait "$ENGINE_PID" 2>/dev/null || true +fi case "$exit_code" in 0) log "Stirling PDF exited normally." ;; 137) log "Stirling PDF was OOM-killed (exit 137). Check container memory limits." ;;