mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Merge branch 'main' into pdf-text-editor-v2-pdfium-2
This commit is contained in:
@@ -96,6 +96,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.*
|
||||
|
||||
+2
-13
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -351,7 +351,7 @@ 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
|
||||
|
||||
@@ -49,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
|
||||
@@ -298,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: >
|
||||
@@ -715,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -703,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
|
||||
|
||||
@@ -795,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
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
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
|
||||
|
||||
@@ -77,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
|
||||
|
||||
@@ -217,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
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -18,7 +18,7 @@ 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'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:9.3.2'
|
||||
|
||||
+16
-17
@@ -91,24 +91,32 @@ public class ControllerAuditAspect {
|
||||
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = sig.getMethod();
|
||||
|
||||
// Fast path: check if auditing is enabled before doing any work
|
||||
// This avoids all data collection if auditing is disabled
|
||||
if (!auditService.shouldAudit(method, auditConfig)) {
|
||||
// Resolve the event type up front so the enterprise gate can be type-aware: document
|
||||
// processing events (the Documents tab's data source) are audited without an Enterprise
|
||||
// license, while the rest of the audit log stays Enterprise-only. resolveEventType is cheap
|
||||
// (annotation / class / path checks), so it's safe on the pre-record fast path.
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
String path = getRequestPath(method, httpMethod);
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Fast path: skip all data collection when this event won't be recorded.
|
||||
if (!auditService.shouldAudit(eventType, method, auditConfig)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// Check if method is explicitly annotated with @Audited
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel level = auditConfig.getAuditLevel();
|
||||
|
||||
// If @Audited annotation is present, respect its level setting
|
||||
if (auditedAnnotation != null) {
|
||||
// Use the level from annotation if it's stricter than global level
|
||||
level = auditedAnnotation.level();
|
||||
}
|
||||
|
||||
String path = getRequestPath(method, httpMethod);
|
||||
|
||||
// Skip static GET resources
|
||||
if ("GET".equals(httpMethod)) {
|
||||
HttpServletRequest maybe = auditService.getCurrentRequest();
|
||||
@@ -209,15 +217,6 @@ public class ControllerAuditAspect {
|
||||
// the body ran, so it must happen here rather than with the pre-proceed HTTP data).
|
||||
auditService.addAutomationContext(data, req);
|
||||
|
||||
// Resolve the event type using the unified method
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Add result only if operation result capture is explicitly enabled
|
||||
// Skip result for UI_DATA events to avoid storing large response bodies
|
||||
if (auditService.shouldCaptureOperationResults()
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Self-hosted default: any portal user sees the whole-server documents queue. */
|
||||
@Component
|
||||
public class DefaultPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
/** Resolves which slice of the documents queue a portal user may see. */
|
||||
public interface PortalDocumentsScopeResolver {
|
||||
|
||||
PortalAuditScope resolve();
|
||||
}
|
||||
+42
-7
@@ -9,6 +9,7 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -20,12 +21,13 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.tool.ToolFormat;
|
||||
import stirling.software.common.model.tool.ToolIO;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
@@ -48,11 +50,13 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
|
||||
* engine to classify the document against the built-in label set, and stores the engine's JSON
|
||||
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
|
||||
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
|
||||
* client use.
|
||||
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF.
|
||||
*
|
||||
* <p>Published in the API spec rather than hidden, so the tool-model generator emits it and a
|
||||
* pipeline can name it as a step like any other tool. Classification is a thing a pipeline does,
|
||||
* not a thing only the Classification policy may do.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
@@ -99,19 +103,31 @@ public class ClassifyLabelController {
|
||||
}
|
||||
|
||||
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
// PDF in, the same PDF out with a verdict on it, so a chain can be checked across this step.
|
||||
@ToolIO(accepts = ToolFormat.PDF, produces = ToolFormat.PDF)
|
||||
@Operation(
|
||||
summary = "Classify a PDF and label its metadata",
|
||||
description =
|
||||
"Reads the first two and last two pages, classifies the document via the AI"
|
||||
+ " engine, and stores the result in the StirlingPDFClassification"
|
||||
+ " metadata field. Dispatched by the Classification policy; not"
|
||||
+ " intended for direct client use.")
|
||||
+ " metadata field. A document that already carries a verdict is"
|
||||
+ " passed through untouched unless reclassify=true.")
|
||||
public ResponseEntity<Resource> classifyAndLabel(
|
||||
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
||||
@RequestParam("fileInput") MultipartFile fileInput,
|
||||
@RequestParam(value = "reclassify", defaultValue = "false") boolean reclassify)
|
||||
throws IOException {
|
||||
aiFeatureGate.requireClassify();
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||
|
||||
if (!reclassify && isClassified(document)) {
|
||||
// Classifying twice costs a second engine call and charges for it, and a document
|
||||
// that already carries a verdict has nothing new to learn. A pipeline can run this
|
||||
// step over a mixed batch without paying for the ones already done.
|
||||
log.debug("[classify-and-label] {} already classified; passing through", fileName);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
|
||||
List<EngineLabel> allowed = resolveAllowedLabels();
|
||||
if (allowed.isEmpty()) {
|
||||
// No vocabulary to classify against: pass the file through unlabelled rather than
|
||||
@@ -135,6 +151,25 @@ public class ClassifyLabelController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a verdict is already on the document.
|
||||
*
|
||||
* <p>This only reads back what a previous run of this step wrote. It is not a statement that
|
||||
* the verdict is trustworthy: the key is ordinary PDF metadata that whoever supplied the file
|
||||
* can set. Skipping the engine on the strength of it is safe because the cost of being wrong is
|
||||
* a missing re-classification, not a wrong decision. Anything that makes a SECURITY decision
|
||||
* from this field - routing a document somewhere on the strength of its label, say - must
|
||||
* classify with {@code reclassify=true} rather than trust what arrived.
|
||||
*/
|
||||
private static boolean isClassified(PDDocument document) {
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
if (info == null) {
|
||||
return false;
|
||||
}
|
||||
String existing = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY);
|
||||
return existing != null && !existing.isBlank();
|
||||
}
|
||||
|
||||
private List<AiPageText> extractWindow(PDDocument document) throws IOException {
|
||||
List<AiPageText> pages = new ArrayList<>();
|
||||
for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) {
|
||||
|
||||
+14
-6
@@ -2,6 +2,7 @@ package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@@ -11,19 +12,25 @@ import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.service.PortalDocumentsService;
|
||||
|
||||
/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */
|
||||
/**
|
||||
* Serves the portal Documents review queue, derived from real audit data and scoped per caller.
|
||||
*
|
||||
* <p>Open to every portal user (not Enterprise-gated): the Documents tab is a core Processor
|
||||
* feature. Access is enforced by {@code @resourceAccess.canUsePortal()}; visibility is then
|
||||
* resolved per deployment - self-hosted portal users see the whole server, SaaS users see their
|
||||
* team (see {@link PortalDocumentsScopeResolver}).
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
@PreAuthorize("@resourceAccess.canUsePortal()")
|
||||
public class PortalDocumentsController {
|
||||
|
||||
private final PortalDocumentsService portalDocumentsService;
|
||||
private final PortalAuditScopeResolver auditScopeResolver;
|
||||
private final PortalDocumentsScopeResolver documentsScopeResolver;
|
||||
|
||||
// tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped).
|
||||
@GetMapping("/documents")
|
||||
@@ -32,8 +39,9 @@ public class PortalDocumentsController {
|
||||
description = "Files processed through the org, derived from the audit trail.")
|
||||
public ResponseEntity<PortalDocumentsResponseDto> getDocuments(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
PortalAuditScope scope = auditScopeResolver.resolve();
|
||||
PortalAuditScope scope = documentsScopeResolver.resolve();
|
||||
if (!scope.allowed()) {
|
||||
// SaaS caller with no team has nothing to show; surface an empty tab, not a 500.
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
PortalDocumentsResponseDto body =
|
||||
|
||||
@@ -85,6 +85,11 @@ public record Policy(
|
||||
return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId);
|
||||
}
|
||||
|
||||
/** A copy under a different owner (e.g. moving a seed off a placeholder name). */
|
||||
public Policy withOwner(String newOwner) {
|
||||
return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId);
|
||||
}
|
||||
|
||||
/** A copy referencing the given saved output destinations. */
|
||||
public Policy withOutputIds(List<String> newOutputIds) {
|
||||
return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId);
|
||||
|
||||
+32
-9
@@ -22,8 +22,8 @@ import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
|
||||
/**
|
||||
* Seeds an enabled Classification policy per team so classification is on by default. Idempotent;
|
||||
* skips the internal team.
|
||||
* Seeds an enabled Classification policy per team; idempotent, skips the internal team. Left
|
||||
* unowned: nobody created it, and an owner here would have to name a real user.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -34,6 +34,11 @@ public class DefaultClassificationPolicySeeder {
|
||||
private static final String CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
|
||||
private static final String POLICY_NAME = "Classification Policy";
|
||||
|
||||
/**
|
||||
* Pre-existing seeds used this placeholder, which was never a user; see {@link #repairOwner}.
|
||||
*/
|
||||
private static final String LEGACY_OWNER = "system";
|
||||
|
||||
private final PolicyStore policyStore;
|
||||
private final TeamRepository teamRepository;
|
||||
|
||||
@@ -46,9 +51,8 @@ public class DefaultClassificationPolicySeeder {
|
||||
.ifPresent(team -> seedIfMissing(team.getId(), team.getName()));
|
||||
}
|
||||
|
||||
// Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own
|
||||
// transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a
|
||||
// live transaction, which AFTER_COMMIT cannot offer.
|
||||
// Seeds inside the new team's own transaction: rollback leaves no policy behind, and the
|
||||
// store's pessimistic lock needs a live transaction, which AFTER_COMMIT cannot offer.
|
||||
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
|
||||
public void onTeamCreated(TeamCreatedEvent event) {
|
||||
seedIfMissing(event.teamId(), event.teamName());
|
||||
@@ -58,16 +62,33 @@ public class DefaultClassificationPolicySeeder {
|
||||
if (teamId == null || TeamService.INTERNAL_TEAM_NAME.equals(teamName)) {
|
||||
return;
|
||||
}
|
||||
boolean alreadySeeded =
|
||||
Policy existing =
|
||||
policyStore.findByTeam(teamId).stream()
|
||||
.anyMatch(DefaultClassificationPolicySeeder::isClassification);
|
||||
if (alreadySeeded) {
|
||||
.filter(DefaultClassificationPolicySeeder::isClassification)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (existing != null) {
|
||||
repairOwner(existing);
|
||||
return;
|
||||
}
|
||||
policyStore.save(defaultPolicy(teamId));
|
||||
log.info("Seeded default Classification policy for team {}", teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear an owner seeded as a placeholder name. An owner someone deliberately set is left alone.
|
||||
*/
|
||||
private void repairOwner(Policy policy) {
|
||||
if (!LEGACY_OWNER.equals(policy.owner())) {
|
||||
return;
|
||||
}
|
||||
policyStore.save(policy.withOwner(null));
|
||||
log.info(
|
||||
"Cleared placeholder owner '{}' on Classification policy {}",
|
||||
LEGACY_OWNER,
|
||||
policy.id());
|
||||
}
|
||||
|
||||
private static boolean isClassification(Policy policy) {
|
||||
return policy.output() != null
|
||||
&& CATEGORY.equals(policy.output().options().get("categoryId"));
|
||||
@@ -85,7 +106,9 @@ public class DefaultClassificationPolicySeeder {
|
||||
return new Policy(
|
||||
null,
|
||||
POLICY_NAME,
|
||||
"system",
|
||||
// Nobody created this - it is seeded. A name here would have to be a real user, and
|
||||
// every consumer of owner already handles its absence.
|
||||
null,
|
||||
true,
|
||||
List.of(),
|
||||
List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())),
|
||||
|
||||
+33
-3
@@ -5,13 +5,13 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
@@ -20,15 +20,31 @@ import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
/** Service to periodically clean up old audit events based on retention policy. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditCleanupService {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
private final boolean runningEE;
|
||||
|
||||
// Default batch size for deletions
|
||||
private static final int BATCH_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* Maximum audit retention on non-Enterprise instances. Audit events feed the Documents tab on
|
||||
* every instance, but longer history is an Enterprise feature - so non-EE deployments keep a
|
||||
* shorter window ("infinite" included), bounding the always-on trail off-license.
|
||||
*/
|
||||
private static final int NON_EE_MAX_RETENTION_DAYS = 30;
|
||||
|
||||
public AuditCleanupService(
|
||||
PersistentAuditEventRepository auditRepository,
|
||||
AuditConfigurationProperties auditConfig,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
this.auditRepository = auditRepository;
|
||||
this.auditConfig = auditConfig;
|
||||
this.runningEE = runningEE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled task that runs daily to clean up old audit events. The retention period is
|
||||
* configurable in settings.yml.
|
||||
@@ -39,7 +55,7 @@ public class AuditCleanupService {
|
||||
return;
|
||||
}
|
||||
|
||||
int retentionDays = auditConfig.getRetentionDays();
|
||||
int retentionDays = effectiveRetentionDays();
|
||||
if (retentionDays <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -58,6 +74,20 @@ public class AuditCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The retention window actually applied. Enterprise uses the configured value (0 = infinite);
|
||||
* non-Enterprise is clamped to {@link #NON_EE_MAX_RETENTION_DAYS}.
|
||||
*/
|
||||
int effectiveRetentionDays() {
|
||||
int configured = auditConfig.getRetentionDays();
|
||||
if (runningEE) {
|
||||
return configured;
|
||||
}
|
||||
return configured <= 0
|
||||
? NON_EE_MAX_RETENTION_DAYS
|
||||
: Math.min(configured, NON_EE_MAX_RETENTION_DAYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs batch deletion of events to prevent long-running transactions and potential database
|
||||
* locks.
|
||||
|
||||
+55
-16
@@ -87,10 +87,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,8 +123,7 @@ public class AuditService {
|
||||
*/
|
||||
public void audit(
|
||||
String principal, AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,8 +152,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,8 +187,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String principal, String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,9 +217,7 @@ public class AuditService {
|
||||
AuditEventType type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,9 +242,7 @@ public class AuditService {
|
||||
String type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -626,6 +616,55 @@ public class AuditService {
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-aware variant used by the controller aspect, which resolves the event type before
|
||||
* deciding whether to record. Document-processing events feed the Documents tab (available to
|
||||
* every Processor user), so they audit without an Enterprise license; the rest of the audit log
|
||||
* stays Enterprise-only.
|
||||
*/
|
||||
public boolean shouldAudit(
|
||||
AuditEventType eventType, Method method, AuditConfigurationProperties auditConfig) {
|
||||
if (!auditConfig.isEnabled() || !isLicensedToRecord(eventType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel requiredLevel =
|
||||
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
|
||||
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event of this type and level should be persisted: the configured audit level must
|
||||
* include it and the current license must permit recording it.
|
||||
*/
|
||||
private boolean shouldRecord(AuditEventType type, AuditLevel level) {
|
||||
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
|
||||
}
|
||||
|
||||
private boolean shouldRecord(String type, AuditLevel level) {
|
||||
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current license permits recording this event type. Enterprise records everything;
|
||||
* without it only document-processing events (PDF_PROCESS, FILE_OPERATION) are captured,
|
||||
* because they back the Documents tab that is open to every Processor user (still subject to
|
||||
* audit being enabled at a level that includes them). Everything else stays Enterprise-only.
|
||||
*/
|
||||
private boolean isLicensedToRecord(AuditEventType type) {
|
||||
return runningEE
|
||||
|| type == AuditEventType.PDF_PROCESS
|
||||
|| type == AuditEventType.FILE_OPERATION;
|
||||
}
|
||||
|
||||
private boolean isLicensedToRecord(String type) {
|
||||
return runningEE
|
||||
|| AuditEventType.PDF_PROCESS.name().equals(type)
|
||||
|| AuditEventType.FILE_OPERATION.name().equals(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add timing and response status data to the audit record
|
||||
*
|
||||
|
||||
+14
-7
@@ -76,7 +76,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("shouldAudit false proceeds without recording")
|
||||
void skipsWhenShouldAuditFalse() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(false);
|
||||
when(jp.proceed()).thenReturn("ok");
|
||||
|
||||
Object result = aspect.auditGetMethod(jp);
|
||||
@@ -102,7 +103,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("records success outcome and returns result")
|
||||
void recordsSuccess() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -134,7 +136,8 @@ class ControllerAuditAspectTest {
|
||||
MDC.put("auditPrincipal", "fromMdc");
|
||||
MDC.put("auditOrigin", "API");
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
.thenReturn(new HashMap<>());
|
||||
when(auditService.resolveEventType(
|
||||
@@ -166,7 +169,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("records failure outcome and rethrows")
|
||||
void recordsFailureAndRethrows() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -204,7 +208,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("annotated method proceeds without double-auditing")
|
||||
void annotatedMethodSkips() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("annotatedEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(jp.proceed()).thenReturn("ok");
|
||||
@@ -232,7 +237,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("captures result when enabled and non-UI type")
|
||||
void capturesResult() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -262,7 +268,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("UI_DATA result is not captured")
|
||||
void uiDataResultSkipped() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
|
||||
+48
-1
@@ -14,6 +14,7 @@ import static org.mockito.Mockito.when;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -76,7 +77,7 @@ class ClassifyLabelControllerTest {
|
||||
.thenReturn("{\"outcome\":\"classification\",\"labels\":[\"invoice\"]}");
|
||||
|
||||
try {
|
||||
controller.classifyAndLabel(file);
|
||||
controller.classifyAndLabel(file, false);
|
||||
} catch (Exception ignored) {
|
||||
// WebResponseUtils.pdfDocToWebResponse needs a real temp file; the engine call and
|
||||
// metadata write we assert on have already happened by the time it runs.
|
||||
@@ -89,6 +90,52 @@ class ClassifyLabelControllerTest {
|
||||
return objectMapper.readTree(body.getValue());
|
||||
}
|
||||
|
||||
/** Stubs a document that already carries a verdict, as a second run over a batch would see. */
|
||||
private MultipartFile alreadyClassifiedDocument() throws Exception {
|
||||
PDDocument document = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
when(document.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY))
|
||||
.thenReturn("{\"labels\":[\"invoice\"]}");
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn("invoice.pdf");
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document);
|
||||
return file;
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_skipsADocumentThatAlreadyCarriesAVerdict() throws Exception {
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
MultipartFile file = alreadyClassifiedDocument();
|
||||
|
||||
try {
|
||||
controller.classifyAndLabel(file, false);
|
||||
} catch (Exception ignored) {
|
||||
// The response needs a real temp file; the decision under test happens before it.
|
||||
}
|
||||
|
||||
// No second engine call, and no charge for one: re-classifying buys the same answer twice.
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), any());
|
||||
verify(pdfMetadataService, never()).setClassificationMetadata(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_reclassifiesWhenAskedTo() throws Exception {
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
MultipartFile file = alreadyClassifiedDocument();
|
||||
when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("Invoice total");
|
||||
when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull()))
|
||||
.thenReturn("{\"outcome\":\"classification\",\"labels\":[\"receipt\"]}");
|
||||
|
||||
try {
|
||||
controller.classifyAndLabel(file, true);
|
||||
} catch (Exception ignored) {
|
||||
// As above.
|
||||
}
|
||||
|
||||
verify(aiEngineClient).post(eq("/api/v1/documents/classify"), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
|
||||
+28
-1
@@ -36,10 +36,14 @@ class DefaultClassificationPolicySeederTest {
|
||||
}
|
||||
|
||||
private static Policy classificationPolicy(Long teamId) {
|
||||
return classificationPolicy(teamId, null);
|
||||
}
|
||||
|
||||
private static Policy classificationPolicy(Long teamId, String owner) {
|
||||
return new Policy(
|
||||
"p1",
|
||||
"Classification Policy",
|
||||
"system",
|
||||
owner,
|
||||
true,
|
||||
List.of(),
|
||||
List.of(),
|
||||
@@ -77,6 +81,29 @@ class DefaultClassificationPolicySeederTest {
|
||||
verify(policyStore, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearsAPlaceholderOwnerSeededBeforeOwnersHadToBeReal() {
|
||||
when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "system")));
|
||||
|
||||
seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
|
||||
|
||||
// "system" was never a user row, and a step dispatch authenticates as the owner. Absence
|
||||
// is handled everywhere; a placeholder name is not.
|
||||
ArgumentCaptor<Policy> saved = ArgumentCaptor.forClass(Policy.class);
|
||||
verify(policyStore).save(saved.capture());
|
||||
assertThat(saved.getValue().owner()).isNull();
|
||||
assertThat(saved.getValue().id()).isEqualTo("p1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesADeliberatelyChosenOwnerAlone() {
|
||||
when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "alice")));
|
||||
|
||||
seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
|
||||
|
||||
verify(policyStore, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotSeedForTheInternalTeam() {
|
||||
seeder().onTeamCreated(new TeamCreatedEvent(2L, "Internal"));
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
|
||||
class AuditCleanupServiceTest {
|
||||
|
||||
private final PersistentAuditEventRepository repository =
|
||||
mock(PersistentAuditEventRepository.class);
|
||||
|
||||
private AuditCleanupService service(boolean runningEE, int retentionDays) {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
var audit = props.getPremium().getEnterpriseFeatures().getAudit();
|
||||
audit.setEnabled(true);
|
||||
audit.setRetentionDays(retentionDays);
|
||||
return new AuditCleanupService(
|
||||
repository, new AuditConfigurationProperties(props), runningEE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Enterprise keeps the configured retention, including infinite")
|
||||
void enterpriseUsesConfigured() {
|
||||
assertThat(service(true, 90).effectiveRetentionDays()).isEqualTo(90);
|
||||
assertThat(service(true, 365).effectiveRetentionDays()).isEqualTo(365);
|
||||
assertThat(service(true, 0).effectiveRetentionDays()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise caps retention at 30 days")
|
||||
void nonEnterpriseCapsHigherValues() {
|
||||
assertThat(service(false, 90).effectiveRetentionDays()).isEqualTo(30);
|
||||
assertThat(service(false, 365).effectiveRetentionDays()).isEqualTo(30);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise respects a shorter configured retention")
|
||||
void nonEnterpriseRespectsLower() {
|
||||
assertThat(service(false, 14).effectiveRetentionDays()).isEqualTo(14);
|
||||
assertThat(service(false, 7).effectiveRetentionDays()).isEqualTo(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise cannot retain forever (<= 0 becomes the cap)")
|
||||
void nonEnterpriseNoInfinite() {
|
||||
assertThat(service(false, 0).effectiveRetentionDays()).isEqualTo(30);
|
||||
assertThat(service(false, -1).effectiveRetentionDays()).isEqualTo(30);
|
||||
}
|
||||
}
|
||||
+36
@@ -166,6 +166,42 @@ class AuditServiceTest {
|
||||
verify(repository, never()).add(any(AuditEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("records document-processing events even without EE (Documents feed)")
|
||||
void recordsDocumentEventsWithoutEE() {
|
||||
AuditService nonEe =
|
||||
new AuditService(
|
||||
repository, auditConfig, false, pdfDocumentFactory, jwtService);
|
||||
authenticateAs("alice");
|
||||
|
||||
nonEe.audit(AuditEventType.PDF_PROCESS, new HashMap<>(), AuditLevel.BASIC);
|
||||
nonEe.audit(AuditEventType.FILE_OPERATION, new HashMap<>(), AuditLevel.BASIC);
|
||||
|
||||
org.mockito.ArgumentCaptor<AuditEvent> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(AuditEvent.class);
|
||||
verify(repository, org.mockito.Mockito.times(2)).add(captor.capture());
|
||||
assertThat(captor.getAllValues())
|
||||
.extracting(AuditEvent::getType)
|
||||
.containsExactly(
|
||||
AuditEventType.PDF_PROCESS.name(),
|
||||
AuditEventType.FILE_OPERATION.name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("type-aware shouldAudit lets doc events through without EE, blocks others")
|
||||
void typeAwareShouldAuditWithoutEE() throws Exception {
|
||||
AuditService nonEe =
|
||||
new AuditService(
|
||||
repository, auditConfig, false, pdfDocumentFactory, jwtService);
|
||||
Method m = Object.class.getMethod("toString");
|
||||
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.PDF_PROCESS, m, auditConfig)).isTrue();
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.FILE_OPERATION, m, auditConfig)).isTrue();
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isFalse();
|
||||
// With EE, non-doc events at/under the configured level audit too.
|
||||
assertThat(service.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips when audit disabled")
|
||||
void skipsWhenDisabled() {
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
|
||||
/**
|
||||
* SaaS documents visibility: platform admins see the whole server; every other portal user sees
|
||||
* their own team's documents (by member email).
|
||||
*/
|
||||
@Component
|
||||
@Primary
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class SaasPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
|
||||
|
||||
private final TeamSecurityExpressions teamSecurity;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
if (PortalAuditScopeResolver.hasAdminAuthority()) {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
Long teamId = teamSecurity.currentUserTeamId();
|
||||
if (teamId == null) {
|
||||
return PortalAuditScope.denied();
|
||||
}
|
||||
List<String> memberEmails =
|
||||
membershipRepository.findByTeamId(teamId).stream()
|
||||
.map(m -> m.getUser() == null ? null : m.getUser().getEmail())
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
return PortalAuditScope.team("team:" + teamId, memberEmails);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,30 @@ COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
|
||||
# Stage 2b: AI engine. Built at its final path so the venv resolves after the copy, on uv's
|
||||
# managed CPython because the runtime base ships Python 3.12.
|
||||
FROM ghcr.io/astral-sh/uv:bookworm-slim@sha256:22334efe746f1b69217d455049b484d7b8cacfb2d5f42555580b62415a98e0a3 AS engine-build
|
||||
ENV UV_PYTHON_INSTALL_DIR=/opt/stirling-engine/python
|
||||
WORKDIR /opt/stirling-engine
|
||||
COPY engine/pyproject.toml engine/uv.lock ./
|
||||
# One layer: trimming in a second RUN would cache the untrimmed copy too, and this build
|
||||
# exports every layer to a GHA cache that is capped repo-wide. Trimming saves ~20MB.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
set -eux; \
|
||||
apt-get update && apt-get install -y --no-install-recommends binutils; \
|
||||
uv python install 3.13; \
|
||||
uv sync --frozen --no-dev --no-install-project --group engine --python-preference only-managed; \
|
||||
P="$(ls -d /opt/stirling-engine/python/cpython-*)"; \
|
||||
rm -rf "$P/share" "$P/include" \
|
||||
"$P/lib/python3.13/idlelib" "$P/lib/python3.13/tkinter" \
|
||||
"$P/lib/python3.13/ensurepip" "$P/lib/python3.13/pydoc_data" \
|
||||
"$P/lib/python3.13/test" "$P/lib/python3.13/lib2to3"; \
|
||||
find /opt/stirling-engine -name '__pycache__' -type d -prune -exec rm -rf {} + ; \
|
||||
find /opt/stirling-engine \( -name '*.so' -o -name '*.so.*' \) -print0 \
|
||||
| xargs -0 -r strip --strip-unneeded 2>/dev/null || true; \
|
||||
apt-get purge -y binutils; apt-get autoremove -y; rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# Stage 3: Final runtime image on top of pre-built base
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
@@ -84,6 +108,12 @@ COPY --link --from=app-build --chown=1000:1000 \
|
||||
/app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
COPY --link --chown=1000:1000 scripts/ /scripts/
|
||||
|
||||
# init-without-ocr.sh starts the engine when this directory exists, so other images are unaffected.
|
||||
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/python /opt/stirling-engine/python
|
||||
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/.venv /opt/stirling-engine/.venv
|
||||
COPY --link --chown=1000:1000 engine/.env /opt/stirling-engine/.env
|
||||
COPY --link --chown=1000:1000 engine/src/ /opt/stirling-engine/src/
|
||||
|
||||
# Fonts go to system dir, root ownership is correct (world-readable)
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
|
||||
|
||||
@@ -97,6 +127,8 @@ RUN set -eux; \
|
||||
ln -s /storage /app/storage; \
|
||||
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app; \
|
||||
mkdir -p /opt/stirling-engine/data; \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup /opt/stirling-engine/data; \
|
||||
chmod 750 /tmp/stirling-pdf; \
|
||||
chmod 750 /tmp/stirling-pdf/heap_dumps; \
|
||||
fc-cache -f
|
||||
@@ -116,6 +148,10 @@ ENV VERSION_TAG=$VERSION_TAG \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
FAT_DOCKER=true \
|
||||
AIENGINE_ENABLED=true \
|
||||
STIRLING_ENGINE_HOME=/opt/stirling-engine \
|
||||
STIRLING_ENGINE_PORT=5001 \
|
||||
STIRLING_ENGINE_WORKERS=2 \
|
||||
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
|
||||
+29
-21
@@ -1,35 +1,43 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca
|
||||
|
||||
ARG TASK_VERSION=3.52.0
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
|
||||
&& dpkg -i /tmp/task.deb \
|
||||
&& rm /tmp/task.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# uv resolves the venv here so its ~52MB binary stays out of the runtime image.
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca AS builder
|
||||
|
||||
# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`.
|
||||
WORKDIR /app/engine
|
||||
COPY engine/pyproject.toml engine/uv.lock engine/.env ./
|
||||
COPY engine/scripts/ ./scripts/
|
||||
COPY engine/pyproject.toml engine/uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --group engine
|
||||
uv sync --frozen --no-dev --no-install-project --group engine
|
||||
|
||||
COPY engine/src/ ./src/
|
||||
WORKDIR /app
|
||||
COPY Taskfile.yml ./
|
||||
COPY .taskfiles/ ./.taskfiles/
|
||||
FROM python:3.13-slim-bookworm@sha256:00faa2debb87529f9f0764e9491d8ba400a3678976616c3bd7cb193745ac20d1 AS runtime
|
||||
|
||||
# Created before the COPYs so they land owned; a later chown -R duplicates the venv layer.
|
||||
RUN set -eux; \
|
||||
groupadd --system --gid 1000 stirling; \
|
||||
useradd --system --uid 1000 --gid 1000 --home /app/engine stirling; \
|
||||
mkdir -p /app/engine/data; \
|
||||
chown stirling:stirling /app/engine /app/engine/data
|
||||
|
||||
WORKDIR /app/engine
|
||||
COPY --from=builder --chown=stirling:stirling /app/engine/.venv ./.venv
|
||||
# settings.py resolves ENGINE_ROOT to /app/engine, so .env must sit here.
|
||||
COPY --chown=stirling:stirling engine/.env ./
|
||||
COPY --chown=stirling:stirling engine/src/ ./src/
|
||||
|
||||
ENV PATH="/app/engine/.venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV STIRLING_ENGINE_WORKERS=4
|
||||
# Container runs on a fixed port; skip the host-only free-port probe (its script
|
||||
# is not shipped in the image). engine:run honours these.
|
||||
ENV ENGINE_PORT_PROBE=false
|
||||
ENV STIRLING_ENGINE_PORT=5001
|
||||
# Fail closed: without a secret the document routes trust caller-supplied X-User-Id.
|
||||
# Set STIRLING_ENGINE_SHARED_SECRET (the backend sends it as X-Engine-Auth), or false to opt out.
|
||||
ENV STIRLING_ENGINE_REQUIRE_AUTH=true
|
||||
|
||||
# `stirling` resolves from the working directory.
|
||||
WORKDIR /app/engine/src
|
||||
USER stirling
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
CMD ["task", "engine:run"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD ["python", "-c", "import os,sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('STIRLING_ENGINE_PORT','5001'), timeout=4).status==200 else 1)"]
|
||||
|
||||
CMD ["sh", "-c", "exec uvicorn stirling.api.app:app --host 0.0.0.0 --port ${STIRLING_ENGINE_PORT:-5001} --workers ${STIRLING_ENGINE_WORKERS:-4}"]
|
||||
|
||||
@@ -16,8 +16,9 @@ engine = [
|
||||
"psycopg[binary,pool]>=3.3.4",
|
||||
"pydantic>=2.13.4",
|
||||
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration.
|
||||
"pydantic-ai>=1.107.2,<2.0.0",
|
||||
"pydantic-ai-slim[voyageai]>=1.107.2,<2.0.0",
|
||||
# Explicit extras: the `pydantic-ai` meta-package pulls all 20 providers (~230MB).
|
||||
# No `voyageai` extra either; stirling.documents.voyage speaks its API directly.
|
||||
"pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0",
|
||||
"pydantic-settings>=2.15.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"sqlite-vec>=0.1.9",
|
||||
|
||||
@@ -70,6 +70,19 @@ Provider credentials (and any local overrides) go in the uncommitted
|
||||
VOYAGE_API_KEY=your-key
|
||||
```
|
||||
|
||||
### Embedding providers
|
||||
|
||||
`STIRLING_RAG_EMBEDDING_MODEL` is a `provider:model` string. Any OpenAI-compatible
|
||||
`/v1/embeddings` endpoint (vLLM, Ollama, TEI, llama.cpp) works by pointing a base URL
|
||||
at it. Note `OPENAI_BASE_URL` is global and also redirects chat completions; push
|
||||
`provider`/`api_key`/`base_url` through admin AI settings to move embeddings only.
|
||||
Ollama reads `OLLAMA_BASE_URL`, and omitting it fails the first embed call, not startup.
|
||||
|
||||
```
|
||||
STIRLING_RAG_EMBEDDING_MODEL=ollama:nomic-embed-text
|
||||
OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
**`sqlite`** - Embedded sqlite-vec. Single `.db` file, zero ops. Ideal for dev
|
||||
|
||||
@@ -6,6 +6,7 @@ from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
from stirling.documents.chunker import chunk_text
|
||||
from stirling.documents.store import Document
|
||||
from stirling.documents.voyage import build_voyage_model
|
||||
|
||||
# Keep each upstream embed request under every major provider's per-call limit while
|
||||
# still batching large enough that a book-sized document ingests in a reasonable number
|
||||
@@ -14,6 +15,9 @@ from stirling.documents.store import Document
|
||||
DEFAULT_EMBED_BATCH_SIZE = 256
|
||||
|
||||
|
||||
VOYAGE_PROVIDER = "voyageai"
|
||||
|
||||
|
||||
def _build_embedder(
|
||||
model_name: str,
|
||||
*,
|
||||
@@ -23,11 +27,17 @@ def _build_embedder(
|
||||
) -> Embedder:
|
||||
"""Construct an :class:`Embedder`; explicit provider/api_key/base_url is the config-push path, else env form."""
|
||||
if not provider and not api_key and not base_url:
|
||||
# Env form is a "provider:model" string; Voyage needs the SDK-free adapter.
|
||||
env_provider, sep, env_model = model_name.partition(":")
|
||||
if sep and env_provider.lower() == VOYAGE_PROVIDER:
|
||||
return Embedder(build_voyage_model(env_model))
|
||||
return Embedder(model_name)
|
||||
|
||||
provider_name = (provider or "").lower()
|
||||
key = api_key or None
|
||||
if provider_name in ("voyageai", "openai"):
|
||||
if provider_name == VOYAGE_PROVIDER:
|
||||
return Embedder(build_voyage_model(model_name, api_key=key, base_url=base_url or None))
|
||||
if provider_name == "openai":
|
||||
return Embedder(f"{provider_name}:{model_name}")
|
||||
if provider_name in ("ollama", "custom"):
|
||||
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
|
||||
|
||||
@@ -2,9 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,11 +21,31 @@ _READ_PERMISSION = "read"
|
||||
# write lock. With multiple worker processes opening the same file, they collide on
|
||||
# startup schema-init and get "database is locked". Wait for the lock instead.
|
||||
_BUSY_TIMEOUT_MS = 5000
|
||||
# journal_mode answers SQLITE_BUSY without consulting the busy handler, so it needs its own retry.
|
||||
_WAL_SWITCH_ATTEMPTS = 10
|
||||
_WAL_RETRY_DELAY_S = 0.1
|
||||
# sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS``
|
||||
# so lexicographic comparison against ``datetime('now')`` matches chronological order.
|
||||
_SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _enable_wal(conn: sqlite3.Connection) -> None:
|
||||
"""Switch the connection to WAL, tolerating workers racing to do the same."""
|
||||
for _ in range(_WAL_SWITCH_ATTEMPTS):
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return
|
||||
except sqlite3.OperationalError:
|
||||
row = conn.execute("PRAGMA journal_mode").fetchone()
|
||||
if row is not None and str(row[0]).lower() == "wal":
|
||||
return # another worker won the race and already switched it
|
||||
time.sleep(_WAL_RETRY_DELAY_S)
|
||||
logger.warning("Could not switch the document store to WAL; continuing on the default journal mode.")
|
||||
|
||||
|
||||
def _to_sqlite_utc(dt: datetime | None) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
@@ -58,7 +80,7 @@ class SqliteVecStore(DocumentStore):
|
||||
if self._db_path is not None:
|
||||
# Set before the WAL switch below: that pragma also takes the lock.
|
||||
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
_enable_wal(conn)
|
||||
|
||||
self._conn = conn
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""VoyageAI embeddings over its OpenAI-shaped REST API.
|
||||
|
||||
The `voyageai` SDK pulls PIL, numpy, tokenizers and langchain at import for multimodal,
|
||||
chunking and local-inference features the engine never uses (~207MB).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pydantic_ai.embeddings import EmbeddingResult, EmbeddingSettings
|
||||
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
|
||||
from pydantic_ai.embeddings.result import EmbedInputType
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
VOYAGE_BASE_URL = "https://api.voyageai.com/v1"
|
||||
VOYAGE_API_KEY_ENV = "VOYAGE_API_KEY"
|
||||
|
||||
# Keeps a keyless engine bootable, and stops the client falling back to OPENAI_API_KEY.
|
||||
_MISSING_API_KEY = "stirling-voyage-api-key-not-configured"
|
||||
|
||||
|
||||
class VoyageEmbeddingModel(OpenAIEmbeddingModel):
|
||||
"""Voyage embeddings spoken over the OpenAI wire format."""
|
||||
|
||||
async def embed(
|
||||
self,
|
||||
inputs: str | Sequence[str],
|
||||
*,
|
||||
input_type: EmbedInputType,
|
||||
settings: EmbeddingSettings | None = None,
|
||||
) -> EmbeddingResult:
|
||||
"""Embed `inputs`, forwarding Voyage's `input_type` that the OpenAI model drops."""
|
||||
if self._client.api_key == _MISSING_API_KEY:
|
||||
raise ValueError(
|
||||
f"VoyageAI embeddings need an API key: set {VOYAGE_API_KEY_ENV} or push one via admin AI settings."
|
||||
)
|
||||
merged: EmbeddingSettings = {**(settings or {})}
|
||||
# extra_body is declared `object`, so narrow rather than assume a mapping.
|
||||
current = merged.get("extra_body")
|
||||
extra_body: dict[str, object] = dict(current) if isinstance(current, dict) else {}
|
||||
extra_body.setdefault("input_type", input_type)
|
||||
merged["extra_body"] = extra_body
|
||||
return await super().embed(inputs, input_type=input_type, settings=merged)
|
||||
|
||||
|
||||
def build_voyage_model(
|
||||
model_name: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> VoyageEmbeddingModel:
|
||||
"""Build a Voyage embedding model; a missing key only fails once an embed is attempted."""
|
||||
key = api_key or os.environ.get(VOYAGE_API_KEY_ENV) or _MISSING_API_KEY
|
||||
provider = OpenAIProvider(base_url=base_url or VOYAGE_BASE_URL, api_key=key)
|
||||
return VoyageEmbeddingModel(model_name, provider=provider)
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from stirling.contracts import PageText
|
||||
@@ -647,3 +649,19 @@ def _dummy_tool_def() -> object:
|
||||
"""Sentinel passed to ``_prepare_search_knowledge``. The callback only inspects
|
||||
``_search_count``; it doesn't read anything off the tool_def or context."""
|
||||
return object()
|
||||
|
||||
|
||||
# concurrent store startup
|
||||
|
||||
|
||||
def test_many_stores_open_the_same_file_without_locking_out(tmp_path: Path) -> None:
|
||||
"""Workers all construct a store against one file on boot; the WAL switch races."""
|
||||
import concurrent.futures
|
||||
|
||||
db_path = tmp_path / "rag.db"
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
stores = list(pool.map(lambda _: SqliteVecStore(db_path), range(8)))
|
||||
|
||||
assert len(stores) == 8
|
||||
mode = stores[0]._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
assert str(mode).lower() == "wal"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic_ai import Embedder
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
from stirling.documents.embedder import _build_embedder
|
||||
from stirling.documents.voyage import VOYAGE_BASE_URL, VoyageEmbeddingModel, build_voyage_model
|
||||
|
||||
# Voyage's documented response body: OpenAI's shape, minus prompt_tokens.
|
||||
VOYAGE_RESPONSE = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "embedding": [0.1, 0.2, 0.3], "index": 0},
|
||||
{"object": "embedding", "embedding": [0.4, 0.5, 0.6], "index": 1},
|
||||
],
|
||||
"model": "voyage-4",
|
||||
"usage": {"total_tokens": 7},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SentRequest:
|
||||
url: str
|
||||
auth: str | None
|
||||
body: dict[str, Any]
|
||||
|
||||
|
||||
def _recording_model(sent: list[SentRequest]) -> VoyageEmbeddingModel:
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
sent.append(
|
||||
SentRequest(
|
||||
url=str(request.url),
|
||||
auth=request.headers.get("authorization"),
|
||||
body=json.loads(request.content),
|
||||
)
|
||||
)
|
||||
return httpx.Response(200, json=VOYAGE_RESPONSE)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
provider = OpenAIProvider(base_url=VOYAGE_BASE_URL, api_key="pa-test-key", http_client=client)
|
||||
return VoyageEmbeddingModel("voyage-4", provider=provider)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_posts_to_voyage_embeddings_endpoint_with_bearer_auth() -> None:
|
||||
sent: list[SentRequest] = []
|
||||
await Embedder(_recording_model(sent)).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert sent[0].url == f"{VOYAGE_BASE_URL}/embeddings"
|
||||
assert sent[0].auth == "Bearer pa-test-key"
|
||||
assert sent[0].body["model"] == "voyage-4"
|
||||
assert sent[0].body["input"] == ["alpha", "beta"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected"),
|
||||
[("embed_query", "query"), ("embed_documents", "document")],
|
||||
)
|
||||
async def test_forwards_voyage_input_type(call: str, expected: str) -> None:
|
||||
"""The stock OpenAI model drops this field; Voyage needs it."""
|
||||
sent: list[SentRequest] = []
|
||||
embedder = Embedder(_recording_model(sent))
|
||||
await getattr(embedder, call)(["text"])
|
||||
|
||||
assert sent[0].body["input_type"] == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_caller_settings_win_over_the_default_input_type() -> None:
|
||||
sent: list[SentRequest] = []
|
||||
await Embedder(_recording_model(sent)).embed_documents(
|
||||
["text"], settings={"extra_body": {"input_type": "query", "output_dimension": 512}}
|
||||
)
|
||||
|
||||
assert sent[0].body["input_type"] == "query"
|
||||
assert sent[0].body["output_dimension"] == 512
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_parses_voyage_response_into_embeddings() -> None:
|
||||
result = await Embedder(_recording_model([])).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert result.embeddings == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
|
||||
|
||||
|
||||
def test_build_voyage_model_reads_the_api_key_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
|
||||
|
||||
assert build_voyage_model("voyage-4").model_name == "voyage-4"
|
||||
|
||||
|
||||
def test_build_voyage_model_without_a_key_still_constructs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
|
||||
assert build_voyage_model("voyage-4").model_name == "voyage-4"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_embedding_without_a_key_fails_with_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
model = build_voyage_model("voyage-4")
|
||||
|
||||
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
|
||||
await Embedder(model).embed_documents(["text"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_an_openai_key_is_never_sent_to_voyage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret")
|
||||
model = build_voyage_model("voyage-4")
|
||||
|
||||
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
|
||||
await Embedder(model).embed_documents(["text"])
|
||||
|
||||
|
||||
def test_env_form_routes_voyageai_through_the_adapter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
|
||||
|
||||
embedder = _build_embedder("voyageai:voyage-4")
|
||||
|
||||
assert isinstance(embedder.model, VoyageEmbeddingModel)
|
||||
assert embedder.model.model_name == "voyage-4"
|
||||
|
||||
|
||||
def test_config_push_form_routes_voyageai_through_the_adapter() -> None:
|
||||
embedder = _build_embedder("voyage-4", provider="voyageai", api_key="pa-pushed-key")
|
||||
|
||||
assert isinstance(embedder.model, VoyageEmbeddingModel)
|
||||
|
||||
|
||||
def test_the_voyageai_sdk_is_not_installed() -> None:
|
||||
"""Guards the ~207MB the SDK would add back."""
|
||||
with pytest.raises(ImportError):
|
||||
__import__("voyageai")
|
||||
|
||||
|
||||
# Live checks, skipped unless VOYAGE_API_KEY is set so CI stays offline.
|
||||
live_only = pytest.mark.skipif(
|
||||
not os.environ.get("VOYAGE_API_KEY"),
|
||||
reason="set VOYAGE_API_KEY to run the live VoyageAI checks",
|
||||
)
|
||||
|
||||
|
||||
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
||||
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_returns_usable_embeddings() -> None:
|
||||
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert len(result.embeddings) == 2
|
||||
assert len(result.embeddings[0]) == 1024
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_honours_input_type_server_side() -> None:
|
||||
"""Voyage embeds the same text differently per input_type."""
|
||||
embedder = Embedder(build_voyage_model("voyage-4"))
|
||||
text = "How do I combine two PDFs?"
|
||||
|
||||
as_query = await embedder.embed_query(text)
|
||||
as_document = await embedder.embed_documents([text])
|
||||
|
||||
assert _cosine(as_query.embeddings[0], as_document.embeddings[0]) < 0.999
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_ranks_the_relevant_document_first() -> None:
|
||||
embedder = Embedder(build_voyage_model("voyage-4"))
|
||||
docs = await embedder.embed_documents(
|
||||
["Stirling PDF merges and splits PDF files.", "The capital of France is Paris."]
|
||||
)
|
||||
query = await embedder.embed_query("How do I combine two PDFs?")
|
||||
|
||||
relevant = _cosine(query.embeddings[0], docs.embeddings[0])
|
||||
irrelevant = _cosine(query.embeddings[0], docs.embeddings[1])
|
||||
assert relevant > irrelevant
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_accepts_voyage_only_parameters() -> None:
|
||||
"""output_dimension has no OpenAI equivalent, so this proves extra_body lands."""
|
||||
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(
|
||||
["dimension test"], settings={"extra_body": {"output_dimension": 256}}
|
||||
)
|
||||
|
||||
assert len(result.embeddings[0]) == 256
|
||||
Generated
+2
-1777
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": [
|
||||
"dist/",
|
||||
"editor/dist/",
|
||||
"editor/src-tauri/**/target/",
|
||||
"editor/src-tauri/gen/",
|
||||
"node_modules/",
|
||||
"editor/public/vendor/",
|
||||
"editor/public/mockServiceWorker.js",
|
||||
"editor/public/og-metadata.json",
|
||||
"editor/public/og-metadata.saas.json",
|
||||
"editor/src/core/data/ogImageMap.json",
|
||||
"editor/src/portal/generated/docsManifest.json",
|
||||
"editor/public/pdfjs*/",
|
||||
"editor/public/js/thirdParty/",
|
||||
"editor/public/css/cookieconsent.css",
|
||||
"storybook-static/",
|
||||
"playwright-report/",
|
||||
"editor/playwright-report/",
|
||||
"test-results/",
|
||||
"editor/test-results/",
|
||||
"*.min.*",
|
||||
"*.md",
|
||||
"*.wxs",
|
||||
"*.toml",
|
||||
"editor/src/output.css"
|
||||
]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
dist/
|
||||
editor/dist/
|
||||
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
|
||||
# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
|
||||
# have their own Cargo workspace under src-tauri/.
|
||||
editor/src-tauri/**/target/
|
||||
editor/src-tauri/gen/
|
||||
node_modules/
|
||||
editor/public/vendor/
|
||||
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
|
||||
editor/public/mockServiceWorker.js
|
||||
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
|
||||
editor/public/og-metadata.json
|
||||
editor/public/og-metadata.saas.json
|
||||
editor/src/core/data/ogImageMap.json
|
||||
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
|
||||
editor/src/portal/generated/docsManifest.json
|
||||
editor/public/pdfjs*/
|
||||
editor/public/js/thirdParty/
|
||||
editor/public/css/cookieconsent.css
|
||||
# Build / test artifacts that may exist locally even though they're gitignored
|
||||
storybook-static/
|
||||
playwright-report/
|
||||
editor/playwright-report/
|
||||
test-results/
|
||||
editor/test-results/
|
||||
*.min.*
|
||||
*.md
|
||||
*.wxs
|
||||
editor/src/output.css
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ For desktop app development, see the [Tauri](#tauri) section below.
|
||||
`frontend/` is a workspace containing one or more apps. Today it holds the
|
||||
PDF editor under `frontend/editor/`; new apps (the developer portal, etc.)
|
||||
will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`,
|
||||
`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs
|
||||
`.storybook/`, oxlint, oxfmt — lives at `frontend/` so every app installs
|
||||
once and lints with the same config.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -4592,6 +4592,10 @@ desc = "Change document restrictions and permissions"
|
||||
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
|
||||
title = "Change Permissions"
|
||||
|
||||
[home.classify]
|
||||
desc = "Identify what kind of document this is and tag it."
|
||||
title = "Classify"
|
||||
|
||||
[home.compare]
|
||||
desc = "Compares and shows the differences between 2 PDF Documents"
|
||||
tags = "difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta"
|
||||
@@ -11044,6 +11048,7 @@ searchPlaceholder = "Search tools..."
|
||||
|
||||
[toolPicker.subcategories]
|
||||
advancedFormatting = "Advanced Formatting"
|
||||
ai = "AI"
|
||||
automation = "Automation"
|
||||
developerTools = "Developer Tools"
|
||||
documentReview = "Document Review"
|
||||
|
||||
@@ -195,6 +195,11 @@
|
||||
"title": "Compress - Stirling PDF",
|
||||
"description": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"classify": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Classify - Stirling PDF",
|
||||
"description": "Identify what kind of document this is and tag it."
|
||||
},
|
||||
"extractPages": {
|
||||
"image": "/og_images/extract-pages.png",
|
||||
"title": "Extract Pages - Stirling PDF",
|
||||
@@ -575,6 +580,7 @@
|
||||
"/remove-cert-sign": "removeCertSign",
|
||||
"/unlock-p-d-f-forms": "unlockPDFForms",
|
||||
"/compress": "compress",
|
||||
"/classify": "classify",
|
||||
"/extract-pages": "extractPages",
|
||||
"/reorganize-pages": "reorganizePages",
|
||||
"/extract-images": "extractImages",
|
||||
|
||||
@@ -196,6 +196,11 @@
|
||||
"title": "Compress - Stirling PDF",
|
||||
"description": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"classify": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Classify - Stirling PDF",
|
||||
"description": "Identify what kind of document this is and tag it."
|
||||
},
|
||||
"extractPages": {
|
||||
"image": "/og_images/extract-pages.png",
|
||||
"title": "Extract Pages - Stirling PDF",
|
||||
@@ -588,6 +593,7 @@
|
||||
"/remove-cert-sign": "removeCertSign",
|
||||
"/unlock-p-d-f-forms": "unlockPDFForms",
|
||||
"/compress": "compress",
|
||||
"/classify": "classify",
|
||||
"/extract-pages": "extractPages",
|
||||
"/reorganize-pages": "reorganizePages",
|
||||
"/extract-images": "extractImages",
|
||||
|
||||
@@ -10,15 +10,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { compile, type JSONSchema } from "json-schema-to-typescript";
|
||||
import * as prettier from "prettier";
|
||||
|
||||
// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/`
|
||||
// is absent from the spec, so it cannot appear here. Extend this list when other
|
||||
// namespaces become tools.
|
||||
//
|
||||
// `/api/v1/filter/` and `/api/v1/integration/` are included even though neither is a
|
||||
// user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O
|
||||
// table, so leaving them out would stop a chain being checked past such a step.
|
||||
// Endpoints a pipeline can reference. filter/integration are not user-facing tools but a stored
|
||||
// pipeline can contain one; the AI namespace is admitted one endpoint at a time, not wholesale.
|
||||
const ALLOWED_PATH_PREFIXES = [
|
||||
"/api/v1/general/",
|
||||
"/api/v1/misc/",
|
||||
@@ -26,6 +20,7 @@ const ALLOWED_PATH_PREFIXES = [
|
||||
"/api/v1/convert/",
|
||||
"/api/v1/filter/",
|
||||
"/api/v1/integration/",
|
||||
"/api/v1/ai/tools/classify-and-label",
|
||||
];
|
||||
|
||||
// File plumbing, not user parameters: `fileInput` and `file` are the uploaded primary document
|
||||
@@ -227,11 +222,7 @@ function collectToolIO(
|
||||
return { table, dropped };
|
||||
}
|
||||
|
||||
async function renderToolIO(
|
||||
spec: Json,
|
||||
table: Record<string, unknown>,
|
||||
outputPath: string,
|
||||
): Promise<string> {
|
||||
function renderToolIO(spec: Json, table: Record<string, unknown>): string {
|
||||
if (Object.keys(table).length === 0) {
|
||||
throw new Error(
|
||||
`No ${IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; regenerate with 'task backend:swagger'.`,
|
||||
@@ -312,33 +303,12 @@ export function toolIOFor(
|
||||
}
|
||||
`;
|
||||
|
||||
const prettierConfig = await prettier.resolveConfig(outputPath);
|
||||
return prettier.format(body, { ...prettierConfig, parser: "typescript" });
|
||||
return body;
|
||||
}
|
||||
|
||||
/** In check mode, fail when the committed file is out of date. */
|
||||
function writeOrCheck(
|
||||
outputPath: string,
|
||||
formatted: string,
|
||||
check: boolean,
|
||||
task: string,
|
||||
): void {
|
||||
if (check) {
|
||||
let current = "";
|
||||
try {
|
||||
current = readFileSync(outputPath, "utf-8");
|
||||
} catch {
|
||||
// Missing file counts as out of date.
|
||||
}
|
||||
if (current !== formatted) {
|
||||
throw new Error(
|
||||
`${outputPath} is out of date. Run '${task}' and commit the result.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
function writeOutput(outputPath: string, contents: string): void {
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, formatted, "utf-8");
|
||||
writeFileSync(outputPath, contents, "utf-8");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -347,12 +317,11 @@ async function main(): Promise<void> {
|
||||
spec: { type: "string" },
|
||||
output: { type: "string" },
|
||||
"io-output": { type: "string" },
|
||||
check: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
if (!values.spec || !values.output || !values["io-output"]) {
|
||||
throw new Error(
|
||||
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> --io-output <file.ts> [--check]",
|
||||
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> --io-output <file.ts>",
|
||||
);
|
||||
}
|
||||
const specPath = resolve(values.spec);
|
||||
@@ -478,14 +447,9 @@ async function main(): Promise<void> {
|
||||
`Dropped ${dropped.length} @ToolIO declaration(s) on paths that are not tool endpoints. Add the namespace to ALLOWED_PATH_PREFIXES if a pipeline can contain these steps:\n ${dropped.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
writeOrCheck(
|
||||
ioOutputPath,
|
||||
await renderToolIO(spec, ioDeclarations, ioOutputPath),
|
||||
values.check ?? false,
|
||||
"task frontend:tool-models",
|
||||
);
|
||||
writeOutput(ioOutputPath, renderToolIO(spec, ioDeclarations));
|
||||
console.log(
|
||||
`${values.check ? "Up to date" : "Generated"}: ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
|
||||
`Generated ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
|
||||
);
|
||||
|
||||
// Transitively inline every referenced component into `definitions`, rewriting its refs too.
|
||||
@@ -508,7 +472,6 @@ async function main(): Promise<void> {
|
||||
definitions,
|
||||
fileFieldsByClass,
|
||||
outputPath,
|
||||
values.check ?? false,
|
||||
skipped,
|
||||
);
|
||||
}
|
||||
@@ -518,7 +481,6 @@ async function compileAndWrite(
|
||||
definitions: Record<string, Json>,
|
||||
fileFieldsByClass: Record<string, string[]>,
|
||||
outputPath: string,
|
||||
check: boolean,
|
||||
skipped: string[],
|
||||
): Promise<void> {
|
||||
// json-schema-to-typescript only emits a named, exported interface per schema
|
||||
@@ -597,17 +559,9 @@ async function compileAndWrite(
|
||||
].join("\n");
|
||||
|
||||
const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`;
|
||||
const prettierConfig = await prettier.resolveConfig(outputPath);
|
||||
const formatted = await prettier.format(body, {
|
||||
...prettierConfig,
|
||||
parser: "typescript",
|
||||
});
|
||||
|
||||
writeOrCheck(outputPath, formatted, check, "task frontend:tool-models");
|
||||
console.log(
|
||||
`${check ? "Up to date" : "Generated"}: ${tools.length} tool endpoints.`,
|
||||
);
|
||||
if (!check && skipped.length > 0) {
|
||||
writeOutput(outputPath, body);
|
||||
console.log(`Generated ${tools.length} tool endpoints.`);
|
||||
if (skipped.length > 0) {
|
||||
console.log(
|
||||
`Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`,
|
||||
);
|
||||
|
||||
@@ -153,7 +153,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
|
||||
if (onShared) {
|
||||
await onShared();
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to generate share link:", error);
|
||||
setErrorMessage(
|
||||
t(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Stack, Card, Text, Flex } from "@mantine/core";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { TooltipTip } from "@app/types/tips";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface CardOption<T = string> {
|
||||
@@ -7,14 +8,14 @@ export interface CardOption<T = string> {
|
||||
prefixKey: string;
|
||||
nameKey: string;
|
||||
tooltipKey?: string;
|
||||
tooltipContent?: any[];
|
||||
tooltipContent?: TooltipTip[];
|
||||
}
|
||||
|
||||
export interface CardSelectorProps<T, K extends CardOption<T>> {
|
||||
options: K[];
|
||||
onSelect: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
getTooltipContent?: (option: K) => any[];
|
||||
getTooltipContent?: (option: K) => TooltipTip[];
|
||||
}
|
||||
|
||||
const CardSelector = <T, K extends CardOption<T>>({
|
||||
|
||||
@@ -269,7 +269,7 @@ export function FileSelectorPicker({
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
const ct = readResponseHeader(res.headers, "content-type");
|
||||
const disp = readResponseHeader(res.headers, "content-disposition");
|
||||
@@ -287,7 +287,7 @@ export function FileSelectorPicker({
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
const ct = readResponseHeader(res.headers, "content-type");
|
||||
const disp = readResponseHeader(res.headers, "content-disposition");
|
||||
|
||||
@@ -208,7 +208,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const openWatchedFolders = useCallback(() => {
|
||||
if (collapsed && onToggleCollapse) onToggleCollapse();
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId: null });
|
||||
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
|
||||
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
}, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions]);
|
||||
|
||||
// Clicking a file's membership dot jumps straight into that folder.
|
||||
@@ -216,7 +216,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
(folderId: string) => {
|
||||
if (collapsed && onToggleCollapse) onToggleCollapse();
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
|
||||
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
|
||||
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
},
|
||||
[collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Modal, Stack, Text, PasswordInput, Alert } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -95,10 +96,13 @@ export default function FirstLoginModal({
|
||||
setTimeout(() => {
|
||||
onPasswordChanged();
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to change password:", err);
|
||||
const message = axios.isAxiosError<{ message?: string }>(err)
|
||||
? err.response?.data?.message
|
||||
: undefined;
|
||||
setError(
|
||||
err.response?.data?.message ||
|
||||
message ||
|
||||
t(
|
||||
"firstLogin.passwordChangeFailed",
|
||||
"Failed to change password. Please check your current password.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { CSSProperties, useMemo, useRef } from "react";
|
||||
import React, { CSSProperties, useCallback, useMemo, useRef } from "react";
|
||||
import { useAdjustFontSizeToFit } from "@app/components/shared/fitText/textFit";
|
||||
|
||||
type FitTextProps = {
|
||||
@@ -28,8 +28,14 @@ const FitText: React.FC<FitTextProps> = ({
|
||||
}) => {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Callback ref: an HTMLElement handler satisfies span's/div's differing ref
|
||||
// types (callback refs are contravariant), so the tag can stay polymorphic.
|
||||
const setRef = useCallback((node: HTMLElement | null) => {
|
||||
ref.current = node;
|
||||
}, []);
|
||||
|
||||
// Hook runs after mount and on size/text changes; uses observers internally
|
||||
useAdjustFontSizeToFit(ref as any, {
|
||||
useAdjustFontSizeToFit(ref, {
|
||||
maxFontSizePx: fontSize,
|
||||
minFontScale: minimumFontScale,
|
||||
maxLines: lines,
|
||||
@@ -38,7 +44,7 @@ const FitText: React.FC<FitTextProps> = ({
|
||||
|
||||
// Memoize the HTML tag to render (span/div) from the `as` prop so
|
||||
// React doesn't create a new component function on each render.
|
||||
const ElementTag: any = useMemo(() => as, [as]);
|
||||
const ElementTag: React.ElementType = useMemo(() => as, [as]);
|
||||
|
||||
// For the / character, insert zero-width soft breaks to prefer wrapping at them
|
||||
const displayText = useMemo(() => {
|
||||
@@ -70,7 +76,7 @@ const FitText: React.FC<FitTextProps> = ({
|
||||
|
||||
return (
|
||||
<ElementTag
|
||||
ref={ref}
|
||||
ref={setRef}
|
||||
className={className}
|
||||
style={{ ...clampStyles, ...style }}
|
||||
>
|
||||
|
||||
@@ -165,7 +165,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
|
||||
if (onUploaded) {
|
||||
await onUploaded();
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to generate share link:", error);
|
||||
setErrorMessage(
|
||||
t(
|
||||
|
||||
@@ -226,7 +226,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to create share link:", error);
|
||||
setErrorMessage(
|
||||
t(
|
||||
|
||||
@@ -18,6 +18,18 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||
import styles from "@app/components/shared/tooltip/Tooltip.module.css";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
|
||||
// The wrapped child's own event handlers, which Tooltip forwards to after
|
||||
// running its own trigger logic. Kept partial since any given child may set none.
|
||||
interface ForwardedHandlers {
|
||||
onPointerEnter?: React.PointerEventHandler;
|
||||
onPointerLeave?: React.PointerEventHandler;
|
||||
onMouseDown?: React.MouseEventHandler;
|
||||
onMouseUp?: React.MouseEventHandler;
|
||||
onClick?: React.MouseEventHandler;
|
||||
onFocus?: React.FocusEventHandler;
|
||||
onBlur?: React.FocusEventHandler;
|
||||
}
|
||||
|
||||
export interface TooltipProps {
|
||||
sidebarTooltip?: boolean;
|
||||
position?: "right" | "left" | "top" | "bottom";
|
||||
@@ -218,7 +230,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const handlePointerEnter = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isPinned && !disabled) openWithDelay();
|
||||
(children.props as any)?.onPointerEnter?.(e);
|
||||
(children.props as ForwardedHandlers).onPointerEnter?.(e);
|
||||
},
|
||||
[isPinned, openWithDelay, children.props, disabled],
|
||||
);
|
||||
@@ -233,19 +245,19 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
tooltipRef.current &&
|
||||
tooltipRef.current.contains(related)
|
||||
) {
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
(children.props as ForwardedHandlers).onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore transient leave between mousedown and click
|
||||
if (clickPendingRef.current) {
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
(children.props as ForwardedHandlers).onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
(children.props as ForwardedHandlers).onPointerLeave?.(e);
|
||||
},
|
||||
[clearTimers, isPinned, setOpen, children.props, allowAutoClose],
|
||||
);
|
||||
@@ -253,7 +265,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
clickPendingRef.current = true;
|
||||
(children.props as any)?.onMouseDown?.(e);
|
||||
(children.props as ForwardedHandlers).onMouseDown?.(e);
|
||||
},
|
||||
[children.props],
|
||||
);
|
||||
@@ -262,7 +274,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
(e: React.MouseEvent) => {
|
||||
// allow microtask turn so click can see this false
|
||||
queueMicrotask(() => (clickPendingRef.current = false));
|
||||
(children.props as any)?.onMouseUp?.(e);
|
||||
(children.props as ForwardedHandlers).onMouseUp?.(e);
|
||||
},
|
||||
[children.props],
|
||||
);
|
||||
@@ -279,7 +291,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
return;
|
||||
}
|
||||
clickPendingRef.current = false;
|
||||
(children.props as any)?.onClick?.(e);
|
||||
(children.props as ForwardedHandlers).onClick?.(e);
|
||||
},
|
||||
[clearTimers, pinOnClick, open, setOpen, children.props],
|
||||
);
|
||||
@@ -288,7 +300,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const handleFocus = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
if (!isPinned && !disabled && openOnFocus) openWithDelay();
|
||||
(children.props as any)?.onFocus?.(e);
|
||||
(children.props as ForwardedHandlers).onFocus?.(e);
|
||||
},
|
||||
[isPinned, openWithDelay, children.props, disabled, openOnFocus],
|
||||
);
|
||||
@@ -301,12 +313,12 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
tooltipRef.current &&
|
||||
tooltipRef.current.contains(related)
|
||||
) {
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
(children.props as ForwardedHandlers).onBlur?.(e);
|
||||
return;
|
||||
}
|
||||
clearTimers();
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
(children.props as ForwardedHandlers).onBlur?.(e);
|
||||
},
|
||||
[isPinned, setOpen, children.props, allowAutoClose, clearTimers],
|
||||
);
|
||||
@@ -339,24 +351,30 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
);
|
||||
|
||||
// Enhance child with handlers and ref
|
||||
const childWithHandlers = React.cloneElement(children as any, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node || null;
|
||||
const originalRef = (children as any).ref;
|
||||
if (typeof originalRef === "function") originalRef(node);
|
||||
else if (originalRef && typeof originalRef === "object")
|
||||
(originalRef as any).current = node;
|
||||
const childWithHandlers = React.cloneElement(
|
||||
children as React.ReactElement<Record<string, unknown>>,
|
||||
{
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node || null;
|
||||
const originalRef = (
|
||||
children as React.ReactElement & { ref?: React.Ref<HTMLElement> }
|
||||
).ref;
|
||||
if (typeof originalRef === "function") originalRef(node);
|
||||
else if (originalRef && typeof originalRef === "object")
|
||||
(originalRef as React.MutableRefObject<HTMLElement | null>).current =
|
||||
node;
|
||||
},
|
||||
"aria-describedby": open ? tooltipIdRef.current : undefined,
|
||||
onPointerEnter: handlePointerEnter,
|
||||
onPointerLeave: handlePointerLeave,
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseUp: handleMouseUp,
|
||||
onClick: handleClick,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onKeyDown: handleKeyDown,
|
||||
},
|
||||
"aria-describedby": open ? tooltipIdRef.current : undefined,
|
||||
onPointerEnter: handlePointerEnter,
|
||||
onPointerLeave: handlePointerLeave,
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseUp: handleMouseUp,
|
||||
onClick: handleClick,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onKeyDown: handleKeyDown,
|
||||
});
|
||||
);
|
||||
|
||||
const shouldShowTooltip = open;
|
||||
const shouldShowCloseButton = showCloseButton || isPinned;
|
||||
|
||||
+11
-4
@@ -25,6 +25,13 @@ export default function WorkbenchBarMobileActions({
|
||||
}: WorkbenchBarActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const exportDisabled = actionsDisabled || policyEnforcing;
|
||||
const showPrint = currentView === "viewer";
|
||||
const showFileActions = !isCustomView;
|
||||
|
||||
// Custom workbench views own their content, so none of these apply. The
|
||||
// desktop cluster renders nothing at all in that case; without this the
|
||||
// trigger would still be there, opening an empty dropdown.
|
||||
if (!showPrint && !showFileActions) return null;
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={230} position="bottom-end">
|
||||
@@ -39,7 +46,7 @@ export default function WorkbenchBarMobileActions({
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{currentView === "viewer" && (
|
||||
{showPrint && (
|
||||
<Menu.Item
|
||||
leftSection={<PrintIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
disabled={exportDisabled}
|
||||
@@ -48,7 +55,7 @@ export default function WorkbenchBarMobileActions({
|
||||
{t("workbenchBar.print", "Print PDF")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCustomView && (
|
||||
{showFileActions && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
@@ -63,7 +70,7 @@ export default function WorkbenchBarMobileActions({
|
||||
{downloadLabel}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCustomView && saveAsIconName && (
|
||||
{showFileActions && saveAsIconName && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon icon={saveAsIconName} width="1.1rem" height="1.1rem" />
|
||||
@@ -74,7 +81,7 @@ export default function WorkbenchBarMobileActions({
|
||||
{t("workbenchBar.saveAs", "Save As")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCustomView && (
|
||||
{showFileActions && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
|
||||
@@ -282,6 +282,9 @@ export default function RightSidebar() {
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={false}
|
||||
/* Mobile keeps the workbench bar - and with it the super search -
|
||||
on the other slide, so the list needs its own filter. */
|
||||
showSearch={isMobile}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
|
||||
@@ -183,6 +183,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* In-panel tool filter. Aligned with .tool-picker__compact's inline padding so
|
||||
the field lines up with the tool rows underneath it. */
|
||||
.tool-panel__search {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem var(--mantine-spacing-sm) 0;
|
||||
}
|
||||
|
||||
.tool-panel__search .search-input-container {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import ToolPicker from "@app/components/tools/ToolPicker";
|
||||
import SearchResults from "@app/components/tools/SearchResults";
|
||||
import ToolRenderer from "@app/components/tools/ToolRenderer";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
|
||||
@@ -20,6 +21,11 @@ interface ToolPanelProps {
|
||||
onToolSelect?: (id: ToolId) => void;
|
||||
/** Whether to render the compact (favourites + recommended only) view. */
|
||||
compact?: boolean;
|
||||
/**
|
||||
* Render a tool filter at the head of the panel. Set where the workbench
|
||||
* bar's super search is out of reach, so the list stays searchable in place.
|
||||
*/
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
|
||||
@@ -28,24 +34,44 @@ export default function ToolPanel({
|
||||
onShowAllTools,
|
||||
onToolSelect,
|
||||
compact: compactProp,
|
||||
showSearch = false,
|
||||
}: ToolPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
leftPanelView,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
selectedToolKey,
|
||||
handleToolSelect,
|
||||
setPreviewFile,
|
||||
} = useToolWorkflow();
|
||||
const selectTool = onToolSelect ?? handleToolSelect;
|
||||
|
||||
// Only offer the filter over the list itself; once a tool is open the panel
|
||||
// belongs to that tool. Deriving the results branch from the same flag keeps
|
||||
// the input and what it filters from drifting apart.
|
||||
const panelSearch = showSearch && leftPanelView === "toolPicker";
|
||||
const searching = searchQuery.trim().length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||
<ToolPanelViewerBar />
|
||||
|
||||
{allToolsView && searchQuery.trim().length > 0 ? (
|
||||
{panelSearch && (
|
||||
<div className="tool-panel__search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searching && (allToolsView || panelSearch) ? (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
@@ -59,7 +85,7 @@ export default function ToolPanel({
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={(id) => selectTool(id as ToolId)}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||
isSearching={searching}
|
||||
compact={compactProp ?? !allToolsView}
|
||||
onShowAllTools={onShowAllTools}
|
||||
/>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function AutomationCreation({
|
||||
setConfigModalOpen(true);
|
||||
};
|
||||
|
||||
const handleToolConfigSave = (parameters: Record<string, any>) => {
|
||||
const handleToolConfigSave = (parameters: Record<string, unknown>) => {
|
||||
if (configuraingToolIndex >= 0) {
|
||||
updateTool(configuraingToolIndex, {
|
||||
configured: true,
|
||||
|
||||
@@ -19,7 +19,7 @@ interface AutomationEntryProps {
|
||||
/** Optional description for tooltip */
|
||||
description?: string;
|
||||
/** MUI Icon component for the badge */
|
||||
badgeIcon?: React.ComponentType<any>;
|
||||
badgeIcon?: React.ComponentType;
|
||||
/** Array of tool operation names in the workflow */
|
||||
operations: string[];
|
||||
/** Click handler */
|
||||
|
||||
@@ -9,11 +9,12 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
|
||||
import { EXECUTION_STATUS } from "@app/constants/automation";
|
||||
import { useResourceCleanup } from "@app/utils/resourceManager";
|
||||
import type { useAutomateOperation } from "@app/hooks/tools/automate/useAutomateOperation";
|
||||
|
||||
interface AutomationRunProps {
|
||||
automation: AutomationConfig;
|
||||
onComplete: () => void;
|
||||
automateOperation?: any; // TODO: Type this properly when available
|
||||
automateOperation?: ReturnType<typeof useAutomateOperation>;
|
||||
}
|
||||
|
||||
export default function AutomationRun({
|
||||
@@ -34,13 +35,13 @@ export default function AutomationRun({
|
||||
// Use the operation hook's loading state
|
||||
const isExecuting = automateOperation?.isLoading || false;
|
||||
const hasResults =
|
||||
automateOperation?.files.length > 0 ||
|
||||
(automateOperation?.files.length ?? 0) > 0 ||
|
||||
automateOperation?.downloadUrl !== null;
|
||||
|
||||
// Initialize execution steps from automation
|
||||
useEffect(() => {
|
||||
if (automation?.operations) {
|
||||
const steps = automation.operations.map((op: any, index: number) => {
|
||||
const steps = automation.operations.map((op, index) => {
|
||||
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
|
||||
return {
|
||||
id: `${op.operation}-${index}`,
|
||||
@@ -125,7 +126,7 @@ export default function AutomationRun({
|
||||
// Mark all as completed and reset current step
|
||||
setCurrentStepIndex(-1);
|
||||
console.log(`✅ Automation completed successfully`);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Automation execution failed:", error);
|
||||
setCurrentStepIndex(-1);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Stack, Text, ScrollArea } from "@mantine/core";
|
||||
import {
|
||||
ToolRegistryEntry,
|
||||
ToolRegistry,
|
||||
SubcategoryId,
|
||||
getToolSupportsAutomate,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import { useToolSections } from "@app/hooks/useToolSections";
|
||||
@@ -81,9 +82,7 @@ export default function ToolSelector({
|
||||
}, [filteredTools]);
|
||||
|
||||
// Use the same tool sections logic as the main ToolPicker
|
||||
const { sections, searchGroups } = useToolSections(
|
||||
transformedFilteredTools as any /* FIX ME */,
|
||||
);
|
||||
const { sections, searchGroups } = useToolSections(transformedFilteredTools);
|
||||
|
||||
// Determine what to display: search results or organized sections
|
||||
const isSearching = searchTerm.trim().length > 0;
|
||||
@@ -98,7 +97,9 @@ export default function ToolSelector({
|
||||
return [
|
||||
{
|
||||
name: "Tools",
|
||||
subcategoryId: "all" as any,
|
||||
// Synthetic "all tools" group used only as a fallback when the
|
||||
// taxonomy produces no sections; "all" is not a real SubcategoryId.
|
||||
subcategoryId: "all" as unknown as SubcategoryId,
|
||||
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })),
|
||||
},
|
||||
];
|
||||
@@ -125,7 +126,7 @@ export default function ToolSelector({
|
||||
displayGroups.map((subcategory) =>
|
||||
renderToolButtons(
|
||||
t,
|
||||
subcategory as any,
|
||||
subcategory,
|
||||
null,
|
||||
handleToolSelect,
|
||||
!isSearching,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useFileActions,
|
||||
} from "@app/contexts/FileContext";
|
||||
import { useFileWithUrl } from "@app/hooks/useFileWithUrl";
|
||||
import { ZoomMode } from "@embedpdf/plugin-zoom/react";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF";
|
||||
import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar";
|
||||
@@ -418,7 +419,7 @@ const EmbedPdfViewerContent = ({
|
||||
return;
|
||||
case "0":
|
||||
event.preventDefault();
|
||||
zoomActions.requestZoom("fit-width");
|
||||
zoomActions.requestZoom(ZoomMode.FitWidth);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,6 @@ export function SelectionAPIBridge() {
|
||||
|
||||
const buildApi = () => ({
|
||||
copyToClipboard: () => selection.copyToClipboard(),
|
||||
getSelectedText: () => selection.getSelectedText(),
|
||||
getFormattedSelection: () => selection.getFormattedSelection(),
|
||||
selectAll: async (totalPages: number) => {
|
||||
const docId = activeDocumentId;
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ZoomState,
|
||||
} from "@app/contexts/viewer/viewerBridges";
|
||||
import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models";
|
||||
import { ZoomLevel, Point } from "@embedpdf/plugin-zoom";
|
||||
import { FormattedSelection } from "@embedpdf/plugin-selection";
|
||||
|
||||
export interface ScrollActions {
|
||||
scrollToPage: (page: number, behavior?: "smooth" | "instant") => void;
|
||||
@@ -19,7 +21,7 @@ export interface ZoomActions {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
toggleMarqueeZoom: () => void;
|
||||
requestZoom: (level: any, center?: any) => void;
|
||||
requestZoom: (level: ZoomLevel, center?: Point) => void;
|
||||
setZoomLevel: (factor: number) => void;
|
||||
}
|
||||
|
||||
@@ -31,8 +33,7 @@ export interface PanActions {
|
||||
|
||||
export interface SelectionActions {
|
||||
copyToClipboard: () => void;
|
||||
getSelectedText: () => string;
|
||||
getFormattedSelection: () => any;
|
||||
getFormattedSelection: () => FormattedSelection[] | null;
|
||||
selectAll: (totalPages: number) => Promise<boolean>;
|
||||
selectWordAt: (pageIndex: number, x: number, y: number) => boolean;
|
||||
}
|
||||
@@ -51,7 +52,7 @@ export interface RotationActions {
|
||||
}
|
||||
|
||||
export interface SearchActions {
|
||||
search: (query: string) => Promise<any> | undefined;
|
||||
search: (query: string) => Promise<unknown> | undefined;
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
clear: () => void;
|
||||
@@ -214,7 +215,7 @@ export function createViewerActions({
|
||||
api.toggleMarqueeZoom();
|
||||
}
|
||||
},
|
||||
requestZoom: (level: any, center?: any) => {
|
||||
requestZoom: (level: ZoomLevel, center?: Point) => {
|
||||
const api = registry.current.zoom?.api;
|
||||
if (api?.requestZoom) {
|
||||
api.requestZoom(level, center);
|
||||
@@ -257,13 +258,6 @@ export function createViewerActions({
|
||||
api.copyToClipboard();
|
||||
}
|
||||
},
|
||||
getSelectedText: () => {
|
||||
const api = registry.current.selection?.api;
|
||||
if (api?.getSelectedText) {
|
||||
return api.getSelectedText() ?? "";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
getFormattedSelection: () => {
|
||||
const api = registry.current.selection?.api;
|
||||
if (api?.getFormattedSelection) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { SpreadMode } from "@embedpdf/plugin-spread/react";
|
||||
import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models";
|
||||
import { ZoomLevel, Point } from "@embedpdf/plugin-zoom";
|
||||
import { FormattedSelection } from "@embedpdf/plugin-selection";
|
||||
|
||||
export enum PdfPermissionFlag {
|
||||
Print = 0x0004,
|
||||
@@ -46,7 +48,7 @@ export interface ZoomAPIWrapper {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
toggleMarqueeZoom: () => void;
|
||||
requestZoom: (level: any, center?: any) => void;
|
||||
requestZoom: (level: ZoomLevel, center?: Point) => void;
|
||||
}
|
||||
|
||||
export interface PanAPIWrapper {
|
||||
@@ -58,8 +60,7 @@ export interface PanAPIWrapper {
|
||||
|
||||
export interface SelectionAPIWrapper {
|
||||
copyToClipboard: () => void;
|
||||
getSelectedText: () => string | any;
|
||||
getFormattedSelection: () => any;
|
||||
getFormattedSelection: () => FormattedSelection[];
|
||||
selectAll: (totalPages: number) => Promise<boolean>;
|
||||
selectWordAt: (pageIndex: number, x: number, y: number) => boolean;
|
||||
}
|
||||
@@ -79,7 +80,7 @@ export interface RotationAPIWrapper {
|
||||
}
|
||||
|
||||
export interface SearchAPIWrapper {
|
||||
search: (query: string) => Promise<any>;
|
||||
search: (query: string) => Promise<unknown>;
|
||||
clear: () => void;
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Classification is a thing a pipeline can do, not a thing only the Classification policy may do.
|
||||
*
|
||||
* The chain that used to stop it: `getExecutableTools` drops any tool whose endpoint is not a
|
||||
* member of the generated `ToolEndpoint` union; that union comes from the OpenAPI spec, gated by
|
||||
* the generator's namespace allowlist; and the classify controller was `@Hidden`, so it never
|
||||
* reached the spec at all. These tests pin each link, because any one of them silently removes the
|
||||
* step from the builder's picker rather than failing loudly.
|
||||
*/
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||
import { getExecutableTools } from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { isToolEndpoint } from "@app/hooks/tools/shared/toolApiMapping";
|
||||
import { TOOL_IO } from "@app/types/toolIO";
|
||||
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
i18n: { changeLanguage: vi.fn(), language: "en-US" },
|
||||
}),
|
||||
Trans: ({ children }: { children?: unknown }) => children,
|
||||
}));
|
||||
|
||||
const CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
|
||||
|
||||
describe("classify as a pipeline task", () => {
|
||||
test("the classify endpoint is a generated ToolEndpoint", () => {
|
||||
// Fails if the controller goes back to @Hidden, or the generator's allowlist drops the
|
||||
// /api/v1/ai/tools/ namespace, or nobody regenerated after either.
|
||||
expect(isToolEndpoint(CLASSIFY_ENDPOINT)).toBe(true);
|
||||
});
|
||||
|
||||
test("the builder offers it as a step", () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
|
||||
const executable = getExecutableTools(result.current.regularTools);
|
||||
const classify = executable.find((tool) => tool.toolId === "classify");
|
||||
|
||||
expect(classify).toBeDefined();
|
||||
expect(classify?.endpoint).toBe(CLASSIFY_ENDPOINT);
|
||||
});
|
||||
|
||||
test("it declares PDF in, PDF out, so a chain can be checked across it", () => {
|
||||
// Without this the builder shows "Can't check what this step accepts" and validation stops
|
||||
// dead at the step - the I/O table is keyed by endpoint and comes from @ToolIO in the spec.
|
||||
expect(TOOL_IO[CLASSIFY_ENDPOINT]).toEqual({
|
||||
accepts: ["PDF"],
|
||||
produces: "PDF",
|
||||
arity: "SISO",
|
||||
});
|
||||
});
|
||||
|
||||
test("it is offered to pipelines but kept out of the editor's tool list", () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
|
||||
// There is no interactive classify tool to open - it only means something inside a pipeline.
|
||||
expect(result.current.regularTools.classify?.hiddenFromToolList).toBe(true);
|
||||
expect(
|
||||
filterToolRegistryByQuery(result.current.regularTools, "").some(
|
||||
(ranked) => ranked.item[0] === "classify",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("it does not re-classify by default", () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
|
||||
const config = result.current.regularTools.classify?.operationConfig;
|
||||
|
||||
// The step is idempotent unless asked otherwise: a second run on a classified document
|
||||
// would be a second engine call, and a second charge, for the same answer.
|
||||
expect(config?.defaultParameters).toEqual({ reclassify: false });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user