Merge branch 'main' into fix_css_20260729

This commit is contained in:
Ludy
2026-08-08 13:05:23 +02:00
committed by GitHub
128 changed files with 7028 additions and 3566 deletions
+23 -13
View File
@@ -1,18 +1,28 @@
# All PRs must be approved by Frooodle or Ludy87
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Review ownership is assigned to teams where possible.
# Teams can only contain org members, so outside collaborators are listed by hand.
#
# @Stirling-Tools/maintainers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/backend-reviewers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/frontend-reviewers - Frooodle, jbrunton96, ConnorYoh, reecebrowne, EthanHealy01
# @Stirling-Tools/devops-reviewers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/all - all of the above
#
# Outside collaborators (need Write access to count as owners): @Ludy87 @balazs-szucs
# Default owners for everything
* @Stirling-Tools/maintainers @Ludy87
# Backend
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
/app/** @Stirling-Tools/backend-reviewers @Ludy87 @balazs-szucs
#V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
# V2 frontend
/frontend/** @Stirling-Tools/frontend-reviewers @balazs-szucs
/app/core/src/main/resources/static/** @Stirling-Tools/frontend-reviewers @Ludy87 @balazs-szucs
#V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
# V2 docker
/docker/backend/** @Stirling-Tools/devops-reviewers @Ludy87
/docker/frontend/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
/docker/compose/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
#GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
# GHA (all users)
/.github/** @Stirling-Tools/all @Ludy87 @balazs-szucs
-1
View File
@@ -11,7 +11,6 @@
"LaserKaspar",
"sbplat",
"reecebrowne",
"DarioGii",
"ConnorYoh",
"EthanHealy01",
"jbrunton96",
-8
View File
@@ -73,14 +73,6 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"
typescript-eslint:
patterns:
- "@typescript-eslint/*"
- "typescript-eslint"
eslint:
patterns:
- "eslint"
- "@eslint/*"
vite:
patterns:
- "vite"
+1 -1
View File
@@ -86,7 +86,7 @@ jobs:
fi
fi
else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$is_auth" = true ]; then
should=true
@@ -54,7 +54,6 @@ jobs:
github.event.comment.user.login == 'Ludy87' ||
github.event.comment.user.login == 'balazs-szucs' ||
github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' ||
github.event.comment.user.login == 'jbrunton96' ||
github.event.comment.user.login == 'ConnorYoh'
@@ -211,10 +210,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+6 -10
View File
@@ -40,20 +40,16 @@ jobs:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+11 -1
View File
@@ -60,6 +60,16 @@ jobs:
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -320,7 +330,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: "pip"
+44 -9
View File
@@ -60,8 +60,43 @@ jobs:
with:
filters: .github/config/.files.yaml
build:
gradle-cache-prime:
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Resolve backend dependencies
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
env:
STIRLING_FLAVOR: saas
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
build:
needs: [files-changed, gradle-cache-prime]
permissions:
actions: read
contents: read
@@ -76,7 +111,7 @@ jobs:
# works after Hibernate's ddl-auto=update migrates the schema. Gated on
# the `project` filter so doc-only PRs skip this ~5-minute job.
if: needs.files-changed.outputs.project == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/db-migration-test.yml
@@ -84,7 +119,7 @@ jobs:
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/check-openapi.yml
@@ -120,7 +155,7 @@ jobs:
playwright-e2e-live:
if: needs.files-changed.outputs.frontend == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/e2e-live.yml
@@ -128,7 +163,7 @@ jobs:
playwright-e2e-enterprise:
if: needs.files-changed.outputs.proprietary == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/build-enterprise.yml
@@ -136,7 +171,7 @@ jobs:
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
needs: [files-changed, build, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/check-licence.yml
@@ -144,7 +179,7 @@ jobs:
docker-compose-tests:
if: needs.files-changed.outputs.project == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
actions: write
contents: read
@@ -156,7 +191,7 @@ jobs:
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
permissions:
contents: read
packages: read
@@ -199,7 +234,7 @@ jobs:
# frontend filter, so a CSS-only PR does not pay for a backend build.
generated-models:
if: needs.files-changed.outputs.generated-models == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
pull-requests: write
+9 -3
View File
@@ -42,10 +42,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.0
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+6 -10
View File
@@ -26,20 +26,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+6 -10
View File
@@ -27,20 +27,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+9 -13
View File
@@ -46,20 +46,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -81,7 +77,7 @@ jobs:
# Each lands as a sibling dir under coverage-execs/, with the .exec
# files preserving their original relative paths.
- name: Download all .exec artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: jacoco-exec-*
path: coverage-execs/
@@ -206,7 +202,7 @@ jobs:
# absence on backend-only runs by skipping the download entirely
# when the producer job was not part of this workflow run.
if: inputs.frontend-validation-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-coverage
path: matrix-inputs/vitest/
@@ -216,7 +212,7 @@ jobs:
# e2e-live uploads the artifact with a stable name. Skip the
# download entirely when the producer job did not run.
if: inputs.playwright-e2e-live-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: playwright-frontend-coverage
path: matrix-inputs/playwright/
+8 -12
View File
@@ -30,23 +30,19 @@ jobs:
java-version: 25
distribution: temurin
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
# backend build's; reuse keeps cold-cache cost identical.
# Keep the normal formatting path here so this smoke test exercises the
# same Gradle configuration as the backend build.
- name: Build Stirling-PDF JAR
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
+6 -10
View File
@@ -38,20 +38,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
+6 -11
View File
@@ -25,21 +25,16 @@ jobs:
with:
java-version: "25"
distribution: "temurin"
# Same cache layer as backend-build.yml. Without it every run resolved the
# whole classpath cold and eventually got HTTP 429 from Maven Central.
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Gradle does not retry 429s, and a cold cache resolving the buildscript
# classpath is exactly where Maven Central rate-limits us. Retry it here,
# where a failure is cheap, instead of inside the backgrounded bootRun.
@@ -350,10 +350,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+24 -17
View File
@@ -57,20 +57,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependencies
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -151,10 +147,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -255,10 +257,16 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -467,7 +475,7 @@ jobs:
fi
- name: Build Tauri app
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -497,7 +505,6 @@ jobs:
projectPath: ./frontend/editor
tauriScript: npx tauri
args: ${{ matrix.args }}
updaterJsonKeepUniversal: true
# Bundled libwayland conflicts with the host's on some distros (Fedora
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
@@ -894,7 +901,7 @@ jobs:
# instead of silently shipping a broken auto-update.
- name: Upload binaries to Release
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
# Don't regenerate/append notes on re-runs, and don't force this into the
+1 -1
View File
@@ -75,7 +75,7 @@ jobs:
- name: Generate tags for base image
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
+7 -11
View File
@@ -65,20 +65,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependencies
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Docker Buildx
id: buildx
@@ -129,7 +125,7 @@ jobs:
- name: Generate tags for latest
id: meta
if: env.RUN_MAIN_APP == 'true'
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -177,7 +173,7 @@ jobs:
- name: Generate tags for latest-fat
id: meta-fat
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
@@ -221,7 +217,7 @@ jobs:
- name: Generate tags for ultra-lite
id: meta-lite
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
+9 -3
View File
@@ -39,10 +39,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+12 -6
View File
@@ -179,10 +179,16 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -380,7 +386,7 @@ jobs:
- name: Build Tauri app (signed)
if: env.SIGN_BUNDLE == 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -415,7 +421,7 @@ jobs:
- name: Build Tauri app (unsigned)
if: env.SIGN_BUNDLE != 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
@@ -441,7 +447,7 @@ jobs:
- name: Build Tauri app (Linux AppImage)
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
continue-on-error: true
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
+6 -10
View File
@@ -84,20 +84,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+9 -3
View File
@@ -38,10 +38,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Build with Gradle
run: ./gradlew build
+3
View File
@@ -22,6 +22,9 @@ pipeline/
customFiles/
configs/
watchedFolders/
# The rule above targets the app's runtime watched-folders working dir, but it
# also matches this frontend source component dir; keep the source tracked.
!frontend/editor/src/proprietary/components/watchedFolders/
clientWebUI/
# Scratch dir used by local fixture-regeneration runs (see
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
+5 -14
View File
@@ -260,8 +260,7 @@ tasks:
desc: "Run linting"
deps: [install]
cmds:
- task: lint:eslint
- task: lint:dpdm
- task: lint:oxlint
- task: lint:colors
- task: lint:css
@@ -293,25 +292,17 @@ tasks:
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
lint:eslint:
desc: "Run ESLint linting"
lint:oxlint:
desc: "Run oxlint linting"
deps: [install]
cmds:
- npx eslint --max-warnings=0
lint:dpdm:
desc: "Run circular import linting"
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
- npx oxlint --config oxlint.config.ts --max-warnings=0
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- npx eslint --fix
- npx oxlint --config oxlint.config.ts --fix
lint:css:check:
desc: "Run stylelintrc linting"
+1 -1
View File
@@ -19,6 +19,6 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
"oxc.oxc-vscode", // Oxc (oxlint) extension for JavaScript/TypeScript linting
]
}
+1 -1
View File
@@ -192,7 +192,7 @@ What goes where:
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (all enforced by the linter). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
+1 -1
View File
@@ -158,7 +158,7 @@ Stirling-PDF/
│ │ │ └── locales/ # Internationalization files (JSON)
│ │ └── vite.config.ts # Vite configuration
│ ├── package.json # Shared workspace dependencies
│ └── eslint.config.mjs # Shared lint config
│ └── oxlint.config.ts # Shared lint config
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
+8 -8
View File
@@ -156,6 +156,14 @@
"moduleName": ".*",
"moduleLicense": "GNU GENERAL PUBLIC LICENSE, Version 2 + Classpath Exception"
},
{
"moduleName": ".*",
"moduleLicense": "GNU Lesser Public License"
},
{
"moduleName": ".*",
"moduleLicense": "The GNU Lesser General Public License"
},
{
"moduleName": "com.martiansoftware:jsap",
"moduleLicense": "LGPL"
@@ -224,14 +232,6 @@
"moduleName": "com.google.re2j:re2j",
"moduleLicense": "Go License"
},
{
"moduleName": "com.hubspot:algebra",
"moduleLicense": null
},
{
"moduleName": "com.hubspot.immutables:immutables-exceptions",
"moduleLicense": null
},
{
"moduleName": ".*",
"moduleLicense": "UnRar License"
@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.annotation.PreDestroy;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
@@ -63,6 +64,21 @@ public class JobExecutorService {
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
}
/** Stop the service-owned executor when the application context is closed or restarted. */
@PreDestroy
public void shutdown() {
log.debug("Shutting down job executor");
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
executor.shutdownNow();
}
}
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
}
@@ -225,19 +225,21 @@ public class MobileScannerService {
Path sessionDir = getSafeSessionDirectory(sessionId);
if (Files.exists(sessionDir)) {
// Delete all files in session directory
Files.walk(sessionDir)
.sorted(
(a, b) ->
-a.compareTo(b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
try (var paths = Files.walk(sessionDir)) {
paths.sorted(
(a, b) ->
-a.compareTo(
b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
}
}
log.info("Deleted session: {}", sessionId);
} catch (IllegalArgumentException e) {
@@ -48,6 +48,10 @@ public class TaskManager {
@Value("${stirling.jobResultExpiryMinutes:30}")
private int jobResultExpiryMinutes = 30;
/** Maximum age of a task that never reached a terminal state. */
@Value("${stirling.job.pendingExpiryMinutes:1440}")
private int pendingJobExpiryMinutes = 1440;
private final FileStorage fileStorage;
private final JobStore jobStore;
private final ClusterBackplane clusterBackplane;
@@ -332,19 +336,32 @@ public class TaskManager {
}
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
LocalDateTime pendingExpiryThreshold =
LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
try {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult result = entry.getValue();
// Remove completed jobs that are older than the expiry threshold
if (result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold)) {
boolean expiredCompletedJob =
result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold);
boolean abandonedPendingJob =
!result.isComplete()
&& result.getCreatedAt() != null
&& result.getCreatedAt().isBefore(pendingExpiryThreshold);
// Remove old terminal results and abandoned pending jobs. Without the second
// branch, a client that starts a task and never completes it keeps its result in
// memory forever.
if (expiredCompletedJob || abandonedPendingJob) {
// Clean up file results
cleanupJobFiles(result, entry.getKey());
if (expiredCompletedJob) {
cleanupJobFiles(result, entry.getKey());
}
// Remove the job result
jobResults.remove(entry.getKey());
@@ -1,17 +1,22 @@
package stirling.software.common.util;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.util.concurrent.UncheckedExecutionException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class RegexPatternUtils {
private static final RegexPatternUtils INSTANCE = new RegexPatternUtils();
private final ConcurrentHashMap<PatternKey, Pattern> patternCache = new ConcurrentHashMap<>();
private static final long MAX_CACHED_PATTERNS = 512;
private final Cache<PatternKey, Pattern> patternCache =
CacheBuilder.newBuilder().maximumSize(MAX_CACHED_PATTERNS).build();
private static final String WHITESPACE_REGEX = "\\s++";
private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$";
@@ -51,7 +56,7 @@ public final class RegexPatternUtils {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, 0), this::compilePattern);
return getOrCompile(new PatternKey(regex, 0));
}
/**
@@ -77,7 +82,7 @@ public final class RegexPatternUtils {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, flags), this::compilePattern);
return getOrCompile(new PatternKey(regex, flags));
}
/**
@@ -98,7 +103,7 @@ public final class RegexPatternUtils {
* @return true if pattern is cached, false otherwise
*/
public boolean isCached(String regex, int flags) {
return regex != null && patternCache.containsKey(new PatternKey(regex, flags));
return regex != null && patternCache.getIfPresent(new PatternKey(regex, flags)) != null;
}
/**
@@ -107,7 +112,7 @@ public final class RegexPatternUtils {
* @return number of patterns currently cached
*/
public int getCacheSize() {
return patternCache.size();
return (int) patternCache.size();
}
/**
@@ -115,7 +120,7 @@ public final class RegexPatternUtils {
* useful for testing or memory cleanup in long-running applications.
*/
public void clearCache() {
patternCache.clear();
patternCache.invalidateAll();
log.debug("Regex pattern cache cleared");
}
@@ -141,13 +146,32 @@ public final class RegexPatternUtils {
return false;
}
PatternKey key = new PatternKey(regex, flags);
boolean removed = patternCache.remove(key) != null;
boolean removed = patternCache.getIfPresent(key) != null;
patternCache.invalidate(key);
if (removed) {
log.debug("Removed regex pattern from cache: {} (flags: {})", regex, flags);
}
return removed;
}
private Pattern getOrCompile(PatternKey key) {
try {
return patternCache.get(key, () -> compilePattern(key));
} catch (UncheckedExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof PatternSyntaxException patternSyntaxException) {
throw patternSyntaxException;
}
throw e;
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof PatternSyntaxException patternSyntaxException) {
throw patternSyntaxException;
}
throw new IllegalStateException("Failed to compile regex pattern", cause);
}
}
/**
* Internal method to compile a pattern and handle errors consistently.
*
+1 -1
View File
@@ -57,7 +57,7 @@ dependencies {
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'com.sun.xml.bind:jaxb-core:4.0.9'
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
+12
View File
@@ -0,0 +1,12 @@
{
"com.hubspot.immutables:immutables-exceptions:1.9": {
"name": "The Apache License, Version 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.txt",
"projectUrl": "https://github.com/HubSpot/hubspot-immutables/tree/58628096ac99b286fe4f8bfe12aa3cff0f0589d3"
},
"com.hubspot:algebra:1.5": {
"name": "The Apache License, Version 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.txt",
"projectUrl": "https://github.com/HubSpot/algebra/tree/5d42983fd3a26539df9ba2cbeac32a1bddce0494"
}
}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.config;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.slf4j.MDC;
@@ -12,10 +13,15 @@ import org.springframework.core.task.support.TaskExecutorAdapter;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
import jakarta.annotation.PreDestroy;
@Configuration
@EnableAsync
public class AsyncConfig {
private ExecutorService auditExecutorService;
private ExecutorService aiStreamExecutorService;
/**
* MDC context-propagating task decorator. Copies MDC context from the caller thread to the
* virtual thread executing the task.
@@ -44,8 +50,8 @@ public class AsyncConfig {
@Bean(name = "auditExecutor")
public Executor auditExecutor() {
TaskExecutorAdapter adapter =
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
auditExecutorService = Executors.newVirtualThreadPerTaskExecutor();
TaskExecutorAdapter adapter = new TaskExecutorAdapter(auditExecutorService);
adapter.setTaskDecorator(new MDCContextTaskDecorator());
return adapter;
}
@@ -53,9 +59,25 @@ public class AsyncConfig {
/** Propagates the request's SecurityContext onto background AI-orchestration threads. */
@Bean(name = "aiStreamExecutor")
public Executor aiStreamExecutor() {
TaskExecutorAdapter adapter =
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
aiStreamExecutorService = Executors.newVirtualThreadPerTaskExecutor();
TaskExecutorAdapter adapter = new TaskExecutorAdapter(aiStreamExecutorService);
adapter.setTaskDecorator(new MDCContextTaskDecorator());
return new DelegatingSecurityContextExecutor(adapter);
}
/**
* Close the underlying executors because the exposed Spring adapters do not own their
* lifecycle.
*/
@PreDestroy
void shutdown() {
shutdownExecutor(auditExecutorService);
shutdownExecutor(aiStreamExecutorService);
}
private void shutdownExecutor(ExecutorService executor) {
if (executor != null) {
executor.shutdownNow();
}
}
}
@@ -16,6 +16,8 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientResponseException;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -79,6 +81,21 @@ public class PolicyEngine {
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
/** Stop the service-owned executor when the application context is closed or restarted. */
@PreDestroy
void shutdown() {
log.debug("Shutting down policy engine executor");
asyncExecutor.shutdown();
try {
if (!asyncExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
asyncExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
asyncExecutor.shutdownNow();
}
}
/**
* Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job
* (status/notes/results observable via the job endpoints); its future resolves when the run
@@ -7,12 +7,14 @@ import java.util.concurrent.TimeUnit;
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.storage.model.StorageCleanupEntry;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
@@ -25,6 +27,7 @@ public class StorageCleanupService {
private final StorageProvider storageProvider;
private final StorageCleanupEntryRepository cleanupEntryRepository;
private final FileShareAccessRepository fileShareAccessRepository;
private final FileShareRepository fileShareRepository;
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
@@ -62,12 +65,14 @@ public class StorageCleanupService {
}
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
@Transactional
public void cleanupExpiredShareLinks() {
List<stirling.software.proprietary.storage.model.FileShare> expired =
fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now());
if (expired.isEmpty()) {
return;
}
expired.forEach(fileShareAccessRepository::deleteByFileShare);
fileShareRepository.deleteAll(expired);
}
}
@@ -298,24 +298,44 @@ class LiveValkeyIntegrationTest {
ValkeyRateLimitStore store = newRateLimitStore(factoryA);
String key = "boundary-" + java.util.UUID.randomUUID();
long capacity = 5;
Duration window = Duration.ofMillis(500);
// refillGreedy tops the bucket up continuously, one token every window/capacity. A 500ms
// window left the drain loop only 100ms before a 6th token appeared, so a slow Valkey
// round-trip broke the count; 4s spaces refills 800ms apart, clear of any burst.
Duration window = Duration.ofSeconds(4);
long refillIntervalMs = window.toMillis() / capacity;
long drainStart = System.nanoTime();
int firstAllowed = 0;
for (int i = 0; i < 10; i++) {
if (store.tryConsume(key, capacity, window).allowed()) firstAllowed++;
}
assertEquals(capacity, firstAllowed, "must allow exactly capacity tokens initially");
long drainMs = (System.nanoTime() - drainStart) / 1_000_000;
// Refill never pauses, so a slow drain earns extra tokens honestly - allow exactly the
// number the elapsed time can have produced and no more.
long earned = drainMs / refillIntervalMs;
assertTrue(
firstAllowed >= capacity && firstAllowed <= capacity + earned,
"initial burst must be capacity ("
+ capacity
+ ") plus at most the "
+ earned
+ " token(s) refilled during a "
+ drainMs
+ "ms drain, got "
+ firstAllowed);
Thread.sleep(window.toMillis() + 50);
// A fixed-window limiter would hand back a whole fresh capacity at the boundary; a token
// bucket hands back one token per refill interval.
Thread.sleep(refillIntervalMs + 200);
int secondAllowed = 0;
long start = System.nanoTime();
for (int i = 0; i < 20 && (System.nanoTime() - start) < 20_000_000L; i++) {
for (int i = 0; i < 10; i++) {
if (store.tryConsume(key, capacity, window).allowed()) secondAllowed++;
}
assertTrue(
secondAllowed <= capacity,
"token-bucket must not let a fresh full capacity be consumed instantly across"
+ " the boundary; got "
secondAllowed >= 1 && secondAllowed < capacity,
"one refill interval must yield about one token, not a fresh full window of "
+ capacity
+ "; got "
+ secondAllowed);
}
+13 -2
View File
@@ -6,9 +6,9 @@ plugins {
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "com.diffplug.spotless" version "8.8.0"
id "com.github.jk1.dependency-license-report" version "3.1.2"
id "com.github.jk1.dependency-license-report"
//id "nebula.lint" version "19.0.3"
id "org.sonarqube" version "7.2.3.7755"
id "org.sonarqube" version "7.3.1.8318"
}
import com.github.jk1.license.render.*
@@ -18,6 +18,7 @@ import groovy.xml.XmlSlurper
import org.gradle.api.JavaVersion
import org.gradle.api.tasks.testing.Test
import org.gradle.jvm.toolchain.JavaLanguageVersion
import stirling.software.gradle.ModuleLicenseOverrideFilter
ext {
springBootVersion = "4.0.6"
@@ -550,6 +551,7 @@ gradle.taskGraph.whenReady { graph ->
}
def allProjects = ((subprojects as Set<Project>) + project) as Set<Project>
def moduleLicenseOverridesFile = project.layout.projectDirectory.file("app/license-overrides.json").asFile
licenseReport {
projects = allProjects
@@ -557,6 +559,15 @@ licenseReport {
allowedLicensesFile = project.layout.projectDirectory.file("app/allowed-licenses.json").asFile
outputDir = project.layout.buildDirectory.dir("reports/dependency-license").get().asFile.path
configurations = [ "productionRuntimeClasspath", "runtimeClasspath" ]
filters = [new ModuleLicenseOverrideFilter(moduleLicenseOverridesFile)]
}
tasks.named('generateLicenseReport') {
inputs.file(moduleLicenseOverridesFile)
}
tasks.named('checkLicensePreparation') {
inputs.file(moduleLicenseOverridesFile)
}
// Configure the forked spring boot run task to properly delegate to the stirling-pdf module
+168
View File
@@ -0,0 +1,168 @@
# Dependency license overrides
The backend dependency license report is generated by the
[`com.github.jk1.dependency-license-report`](https://github.com/jk1/Gradle-License-Report)
Gradle plugin. Most license information is read from dependency POM files, manifests, or packaged
license files. Some artifacts do not publish license metadata in a form the plugin can detect, even
though the artifact has a known license.
This directory contains the build logic used to provide narrowly scoped fallback license metadata
for those artifacts.
## Files
- `build.gradle` makes version 3.1.4 of the license report plugin available to the custom build
logic. The root build applies that plugin without a second version declaration so both use the
same classpath.
- `src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy` implements the
plugin's `DependencyFilter` interface.
- `../app/license-overrides.json` contains the actual module-specific fallback values.
- `../app/allowed-licenses.json` defines which detected or supplied licenses are accepted by
`checkLicense`.
## How it works
The root `build.gradle` passes `app/license-overrides.json` to
`ModuleLicenseOverrideFilter`:
```groovy
filters = [new ModuleLicenseOverrideFilter(moduleLicenseOverridesFile)]
```
For every dependency discovered by the license plugin, the filter builds an identifier in this
format:
```text
group:artifact:version
```
The filter applies a populated override only when both conditions are true:
1. The complete identifier, including the version, exists in `app/license-overrides.json`.
2. The plugin did not discover a non-empty license name for that dependency.
When both conditions match, the filter adds the configured license as fallback manifest metadata.
The normal report renderer and `checkLicense` then consume that metadata in the same way as
metadata discovered from the dependency itself.
An override never replaces a license that the plugin already detected. Updating a dependency also
does not silently reuse the override because a different version produces a different identifier.
Overrides are temporary fallbacks, not a permanent license catalog. If the plugin starts detecting
the original license for an overridden module, the filter automatically removes that exact entry
from `app/license-overrides.json` and logs the cleanup. When the overridden version is no longer
resolved, a newer resolved version takes its place: if it declares a license, the stale entry is
removed; otherwise the entry moves to the new exact version and its values are cleared for
re-verification. An already populated entry for the new version is preserved. If no higher version
is resolved, the unused override is removed instead.
Because the report aggregates several projects and configurations, multiple versions of the same
`group:artifact` can be present at once. An override is retained whenever its exact version is still
resolved. Only when that exact version is absent may the filter treat a higher version as an update;
version ordering then follows Gradle's own dependency version comparator. Overrides for dependency
versions that are no longer resolved and have no higher replacement are deleted automatically.
The filter also records every resolved dependency without detected license metadata that has no
override yet. It writes a placeholder with `null` values for `name`, `url`, and `projectUrl`.
Placeholders deliberately do not affect the generated report until `name` is filled in. This makes
new missing metadata visible in the source-controlled override file instead of only in a generated
report. Review and fill or remove every new placeholder before committing the resulting JSON.
## Adding an override
First verify the license from an authoritative source such as the upstream repository, the
published artifact metadata, or the license file shipped inside the artifact. Do not infer a
license from the organization name or from a related artifact.
Add an entry to `app/license-overrides.json`:
```json
{
"com.example:example-library:1.2.3": {
"name": "Apache License, Version 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0",
"projectUrl": "https://github.com/example/example-library/tree/0123456789abcdef0123456789abcdef01234567"
}
}
```
The key must contain the exact resolved version. `name` must be non-empty for the override to be
applied. `url` should point to the canonical license text. `projectUrl` must point to the immutable
Git tree for the exact module version, using the commit hash at which that version was introduced:
```text
https://github.com/<owner>/<repository>/tree/<full-commit-hash>
```
Do not use the repository's default branch or another moving URL. See the existing entries in
`app/license-overrides.json` for concrete examples.
If the license name is not already accepted, add a suitably narrow rule to
`app/allowed-licenses.json`. Adding an override and allowing a license are separate operations:
- `license-overrides.json` supplies missing metadata for a specific artifact version.
- `allowed-licenses.json` defines the policy enforced by `checkLicense`.
## Updating a dependency
When an overridden dependency changes version:
1. Verify the license for the new version again.
2. Run the license report so the filter can move the old key or add a placeholder for the new full
`group:artifact:version` key.
3. Re-verify and fill the license values and the version's immutable Git-tree `projectUrl`; moved
values are intentionally cleared because a license conclusion for one release is not assumed
for another.
4. Regenerate and inspect the report.
If the new artifact publishes usable license metadata, no override is necessary. The next license
report or license check removes the old entry from `app/license-overrides.json` automatically. The
file must contain only overrides that are still needed.
## Verification
Run the filter unit tests:
```powershell
.\gradlew.bat -p buildSrc test
```
The tests use `com.example:example-library` versions 1.4 and 1.7 to cover the missing
metadata fallback, placeholder creation, version migration, preservation of a populated newer
override, automatic cleanup after license metadata appears, exact-version matching, and concurrent
resolved versions. They also verify removal when a dependency version disappears. A separate `1.9`
to `1.11.0` case verifies numeric Gradle version ordering.
Run the normal backend license workflow from the repository root:
```powershell
task backend:licenses:generate
```
Then inspect:
- `build/reports/dependency-license/index.json` for the rendered module, version, license name, and
URL.
- `build/reports/dependency-license/dependencies-without-allowed-license.json` when `checkLicense`
reports a policy failure.
Also run the backend quality gate after changing the filter or its build wiring:
```powershell
task backend:check
```
The override JSON is registered as an input of `generateLicenseReport` and
`checkLicensePreparation`, so changing the file invalidates the corresponding Gradle task outputs.
## What not to do
- Do not use an unversioned key. It cannot match the filter and would make the intended scope
ambiguous.
- Do not use an override to replace valid license metadata published by a dependency.
- Do not add an empty license to `allowed-licenses.json` merely to silence `checkLicense`; that
would still leave the generated report without useful license information.
- Do not exclude a dependency from the report solely because it is transitive. Runtime transitive
dependencies are still distributed components and their licenses remain relevant.
- Do not edit generated files under `build/reports/dependency-license` or the copied static license
report by hand.
+22
View File
@@ -0,0 +1,22 @@
plugins {
id 'groovy'
}
repositories {
gradlePluginPortal()
}
dependencies {
implementation localGroovy()
implementation gradleApi()
implementation 'com.github.jk1:gradle-license-report:3.1.4'
testImplementation platform('org.junit:junit-bom:6.1.2')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
jvmArgs '--add-opens=java.base/java.lang=ALL-UNNAMED'
testLogging.showStandardStreams = true
}
@@ -0,0 +1,173 @@
package stirling.software.gradle
import com.github.jk1.license.License
import com.github.jk1.license.ManifestData
import com.github.jk1.license.ModuleData
import com.github.jk1.license.ProjectData
import com.github.jk1.license.filter.DependencyFilter
import com.github.jk1.license.render.LicenseDataCollector
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.DefaultVersionComparator
import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.Version
import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.VersionParser
class ModuleLicenseOverrideFilter implements DependencyFilter {
private static final VersionParser VERSION_PARSER = new VersionParser()
private static final Comparator<Version> VERSION_COMPARATOR =
new DefaultVersionComparator().asVersionComparator()
private final File overridesFile
ModuleLicenseOverrideFilter(File overridesFile) {
this.overridesFile = overridesFile
}
@Override
ProjectData filter(ProjectData projectData) {
Map<String, Map<String, String>> overrides = loadOverrides()
List<ModuleData> modules = projectData.configurations
.collectMany { configuration -> configuration.dependencies }
Map<String, List<ModuleData>> modulesByCoordinate = modules
.groupBy { module -> moduleCoordinate(module) }
boolean overridesChanged = false
overrides.keySet().toList().each { overrideId ->
ModuleCoordinates overrideModule = parseModuleId(overrideId)
List<ModuleData> coordinateModules = modulesByCoordinate[overrideModule.coordinate]
ModuleData currentModule = coordinateModules
?.find { module -> module.version == overrideModule.version }
if (currentModule == null) {
currentModule = newestModule(coordinateModules, overrideModule.version)
}
if (currentModule == null) {
overrides.remove(overrideId)
overridesChanged = true
projectData.project.logger.lifecycle(
"Removed unused license override for ${overrideId}: "
+ 'dependency version is no longer resolved')
return
}
if (hasDeclaredLicense(currentModule)) {
overrides.remove(overrideId)
overridesChanged = true
projectData.project.logger.lifecycle(
"Removed stale license override for ${overrideId}: "
+ "${moduleId(currentModule)} now declares a license")
return
}
if (compareVersions(currentModule.version, overrideModule.version) > 0) {
String currentModuleId = moduleId(currentModule)
overrides.remove(overrideId)
if (!overrides.containsKey(currentModuleId)) {
overrides[currentModuleId] = [name: null, url: null, projectUrl: null]
}
overridesChanged = true
projectData.project.logger.lifecycle(
"Updated license override from ${overrideId} to ${currentModuleId}: "
+ 'newer dependency still declares no license')
}
}
modules.groupBy { module -> moduleId(module) }.each { currentModuleId, matchingModules ->
ModuleData module = matchingModules.first()
if (!overrides.containsKey(currentModuleId) && !hasDeclaredLicense(module)) {
overrides[currentModuleId] = [name: null, url: null, projectUrl: null]
overridesChanged = true
projectData.project.logger.lifecycle(
"Added missing license override for ${currentModuleId}. "
+ "Set 'name' and 'url' in ${overridesFile}.")
}
}
if (overridesChanged) {
saveOverrides(overrides)
}
projectData.configurations.each { configuration ->
configuration.dependencies.each { module -> applyOverride(module, overrides) }
}
return projectData
}
private void applyOverride(
ModuleData module, Map<String, Map<String, String>> overrides) {
String moduleId = moduleId(module)
Map<String, String> override = overrides[moduleId]
if (override == null) {
return
}
String licenseName = override.name
String licenseUrl = override.url
String projectUrl = override.projectUrl
if (licenseName == null || licenseName.isBlank()) {
return
}
Set<License> licenses = [new License(licenseName, licenseUrl)] as LinkedHashSet<License>
ManifestData manifest =
new ManifestData(module.name, module.version, null, null, projectUrl, licenses, false)
Set<ManifestData> manifests = new LinkedHashSet<>(module.manifests ?: [])
manifests.add(manifest)
module.manifests = manifests
}
private Map<String, Map<String, String>> loadOverrides() {
Object parsed = new JsonSlurper().parse(overridesFile)
if (!(parsed instanceof Map)) {
throw new IllegalArgumentException(
"License overrides file ${overridesFile} must contain a JSON object")
}
return parsed as Map<String, Map<String, String>>
}
private void saveOverrides(Map<String, Map<String, String>> overrides) {
String json = JsonOutput.prettyPrint(JsonOutput.toJson(overrides)) + System.lineSeparator()
overridesFile.setText(json, 'UTF-8')
}
private static String moduleId(ModuleData module) {
return "${module.group}:${module.name}:${module.version}"
}
private static String moduleCoordinate(ModuleData module) {
return "${module.group}:${module.name}"
}
private static ModuleCoordinates parseModuleId(String moduleId) {
List<String> parts = moduleId.split(':', 3) as List<String>
if (parts.size() != 3 || parts.any { part -> part.isBlank() }) {
throw new IllegalArgumentException(
"License override key ${moduleId} must use group:module:version")
}
return new ModuleCoordinates("${parts[0]}:${parts[1]}", parts[2])
}
private static ModuleData newestModule(List<ModuleData> modules, String minimumVersion) {
return modules
?.findAll { module -> compareVersions(module.version, minimumVersion) > 0 }
?.max { left, right -> compareVersions(left.version, right.version) }
}
private static int compareVersions(String left, String right) {
return VERSION_COMPARATOR.compare(
VERSION_PARSER.transform(left), VERSION_PARSER.transform(right))
}
private static boolean hasDeclaredLicense(ModuleData module) {
Set<License> licenses = LicenseDataCollector.multiModuleLicenseInfo(module).licenses
return licenses.any { license -> license.name != null && !license.name.isBlank() }
}
private static class ModuleCoordinates {
final String coordinate
final String version
ModuleCoordinates(String coordinate, String version) {
this.coordinate = coordinate
this.version = version
}
}
}
@@ -0,0 +1,265 @@
package stirling.software.gradle
import com.github.jk1.license.ConfigurationData
import com.github.jk1.license.License
import com.github.jk1.license.ManifestData
import com.github.jk1.license.ModuleData
import com.github.jk1.license.ProjectData
import com.github.jk1.license.render.LicenseDataCollector
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import java.nio.file.Path
import org.gradle.testfixtures.ProjectBuilder
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import static org.junit.jupiter.api.Assertions.assertEquals
import static org.junit.jupiter.api.Assertions.assertFalse
import static org.junit.jupiter.api.Assertions.assertTrue
class ModuleLicenseOverrideFilterTest {
private static final String GROUP = 'com.example'
private static final String MODULE = 'example-library'
private static final String VERSION_WITHOUT_LICENSE = '1.4'
private static final String VERSION_WITH_LICENSE = '1.7'
private static final String APACHE_NAME = 'Apache License, Version 2.0'
private static final String APACHE_URL = 'https://www.apache.org/licenses/LICENSE-2.0'
private static final String PROJECT_URL = 'https://github.com/HubSpot/hubspot-immutables'
@TempDir
Path temporaryDirectory
@Test
void keepsOverrideForVersionWithoutLicenseMetadata() {
ModuleData module = createModule(VERSION_WITHOUT_LICENSE, null)
File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE))
debugState('before filter', module, overridesFile)
new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module))
debugState('after filter', module, overridesFile)
Map<String, Object> overrides = readOverrides(overridesFile)
assertTrue(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE)))
assertEquals([APACHE_NAME], licenseNames(module))
}
@Test
void removesOverrideWhenLaterVersionDeclaresLicense() {
License publishedLicense = new License(APACHE_NAME, APACHE_URL)
ModuleData module = createModule(VERSION_WITH_LICENSE, publishedLicense)
File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE))
debugState('before filter', module, overridesFile)
new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module))
debugState('after filter', module, overridesFile)
Map<String, Object> overrides = readOverrides(overridesFile)
assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE)))
assertEquals([APACHE_NAME], licenseNames(module))
}
@Test
void removesOverrideWhenOnlyOlderVersionIsResolved() {
License publishedLicense = new License(APACHE_NAME, APACHE_URL)
ModuleData module = createModule(VERSION_WITHOUT_LICENSE, publishedLicense)
File overridesFile = createOverridesFile(moduleId(VERSION_WITH_LICENSE))
debugState('before filter', module, overridesFile)
new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module))
debugState('after filter', module, overridesFile)
Map<String, Object> overrides = readOverrides(overridesFile)
assertFalse(overrides.containsKey(moduleId(VERSION_WITH_LICENSE)))
assertEquals([APACHE_NAME], licenseNames(module))
}
@Test
void removesOverrideWhenModuleIsNoLongerResolved() {
File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE))
new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData())
assertTrue(readOverrides(overridesFile).isEmpty())
}
@Test
void keepsOverrideWhenExactAndNewerVersionsAreBothResolved() {
ModuleData olderModule = createModule(VERSION_WITHOUT_LICENSE, null)
License publishedLicense = new License(APACHE_NAME, APACHE_URL)
ModuleData newerModule = createModule(VERSION_WITH_LICENSE, publishedLicense)
File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE))
new ModuleLicenseOverrideFilter(overridesFile)
.filter(createProjectData(olderModule, newerModule))
Map<String, Object> overrides = readOverrides(overridesFile)
assertTrue(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE)))
assertEquals([APACHE_NAME], licenseNames(olderModule))
assertEquals([APACHE_NAME], licenseNames(newerModule))
}
@Test
void movesOverrideUsingGradleNumericVersionOrdering() {
String oldVersion = '1.9'
String newVersion = '1.11.0'
ModuleData module = createModule(newVersion, null)
File overridesFile = createOverridesFile(moduleId(oldVersion))
new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module))
Map<String, Object> overrides = readOverrides(overridesFile)
assertFalse(overrides.containsKey(moduleId(oldVersion)))
assertEquals(
[name: null, url: null, projectUrl: null], overrides[moduleId(newVersion)])
}
@Test
void movesOverrideToLaterVersionWithoutLicenseAndClearsLicenseData() {
ModuleData module = createModule(VERSION_WITH_LICENSE, null)
File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE))
debugState('before filter', module, overridesFile)
new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module))
debugState('after filter', module, overridesFile)
Map<String, Object> overrides = readOverrides(overridesFile)
assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE)))
assertEquals(
[name: null, url: null, projectUrl: null],
overrides[moduleId(VERSION_WITH_LICENSE)])
assertTrue(licenseNames(module).isEmpty())
}
@Test
void preservesExistingOverrideWhenRemovingOlderVersion() {
ModuleData module = createModule(VERSION_WITH_LICENSE, null)
File overridesFile = createOverridesFile(
[
(moduleId(VERSION_WITHOUT_LICENSE)): [
name: APACHE_NAME, url: APACHE_URL
],
(moduleId(VERSION_WITH_LICENSE)): [
name: APACHE_NAME, url: APACHE_URL, projectUrl: PROJECT_URL
]
])
debugState('before filter', module, overridesFile)
new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module))
debugState('after filter', module, overridesFile)
Map<String, Object> overrides = readOverrides(overridesFile)
assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE)))
assertEquals(
[name: APACHE_NAME, url: APACHE_URL, projectUrl: PROJECT_URL],
overrides[moduleId(VERSION_WITH_LICENSE)])
assertEquals([APACHE_NAME], licenseNames(module))
}
@Test
void addsMissingOverrideForModuleWithoutLicense() {
ModuleData module = createModule(VERSION_WITHOUT_LICENSE, null)
File overridesFile = createEmptyOverridesFile()
debugState('before filter', module, overridesFile)
new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module))
debugState('after filter', module, overridesFile)
Map<String, Object> overrides = readOverrides(overridesFile)
assertEquals(
[name: null, url: null, projectUrl: null],
overrides[moduleId(VERSION_WITHOUT_LICENSE)])
assertTrue(licenseNames(module).isEmpty())
}
@Test
void doesNotAddOverrideForModuleWithLicense() {
License publishedLicense = new License(APACHE_NAME, APACHE_URL)
ModuleData module = createModule(VERSION_WITH_LICENSE, publishedLicense)
File overridesFile = createEmptyOverridesFile()
debugState('before filter', module, overridesFile)
new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module))
debugState('after filter', module, overridesFile)
assertTrue(readOverrides(overridesFile).isEmpty())
assertEquals([APACHE_NAME], licenseNames(module))
}
private File createOverridesFile(String moduleId) {
Map<String, Map<String, String>> overrides = [
(moduleId): [name: APACHE_NAME, url: APACHE_URL]
]
return createOverridesFile(overrides)
}
private File createOverridesFile(Map<String, Map<String, String>> overrides) {
File overridesFile = temporaryDirectory.resolve('license-overrides.json').toFile()
overridesFile.setText(JsonOutput.prettyPrint(JsonOutput.toJson(overrides)), 'UTF-8')
return overridesFile
}
private File createEmptyOverridesFile() {
File overridesFile = temporaryDirectory.resolve('license-overrides.json').toFile()
overridesFile.setText('{}', 'UTF-8')
return overridesFile
}
private static Map<String, Object> readOverrides(File overridesFile) {
return new JsonSlurper().parse(overridesFile) as Map<String, Object>
}
private static void debugState(String stage, ModuleData module, File overridesFile) {
String resolvedModuleId = "${module.group}:${module.name}:${module.version}"
Map<String, Object> overrides = readOverrides(overridesFile)
System.out.println(
"[license-override-test] ${stage}: module=${resolvedModuleId}, "
+ "licenses=${licenseNames(module)}, "
+ "matchingOverride=${overrides.containsKey(resolvedModuleId)}, "
+ "overrideKeys=${overrides.keySet().sort()}")
}
private static ProjectData createProjectData(ModuleData module) {
return createProjectData(module as ModuleData[])
}
private static ProjectData createProjectData(ModuleData... modules) {
ConfigurationData configuration =
new ConfigurationData(
'runtimeClasspath', modules as LinkedHashSet<ModuleData>)
return new ProjectData(
ProjectBuilder.builder().build(),
[configuration] as LinkedHashSet<ConfigurationData>)
}
private static ModuleData createModule(String version, License license) {
Set<ManifestData> manifests = new LinkedHashSet<>()
if (license != null) {
manifests.add(
new ManifestData(
MODULE,
version,
null,
null,
null,
[license] as LinkedHashSet<License>,
false))
}
return new ModuleData(
GROUP,
MODULE,
version,
true,
manifests,
new LinkedHashSet<>(),
new LinkedHashSet<>())
}
private static String moduleId(String version) {
return "${GROUP}:${MODULE}:${version}"
}
private static List<String> licenseNames(ModuleData module) {
Set<License> licenses = LicenseDataCollector.multiModuleLicenseInfo(module).licenses
return licenses.collect { license -> license.name }.sort()
}
}
+3 -3
View File
@@ -908,9 +908,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
+2
View File
@@ -17,6 +17,8 @@ WORKDIR /app
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
COPY buildSrc/build.gradle buildSrc/
COPY buildSrc/src/main/ buildSrc/src/main/
COPY app/core/build.gradle app/core/
COPY app/common/build.gradle app/common/
COPY app/proprietary/build.gradle app/proprietary/
+2
View File
@@ -30,6 +30,8 @@ WORKDIR /app
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
COPY buildSrc/build.gradle buildSrc/
COPY buildSrc/src/main/ buildSrc/src/main/
COPY app/core/build.gradle app/core/
COPY app/common/build.gradle app/common/
COPY app/proprietary/build.gradle app/proprietary/
+2
View File
@@ -31,6 +31,8 @@ WORKDIR /app
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
COPY buildSrc/build.gradle buildSrc/
COPY buildSrc/src/main/ buildSrc/src/main/
COPY app/core/build.gradle app/core/
COPY app/common/build.gradle app/common/
COPY app/proprietary/build.gradle app/proprietary/
+2
View File
@@ -23,6 +23,8 @@ WORKDIR /app
# Copy gradle files for dependency resolution
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
COPY buildSrc/build.gradle buildSrc/
COPY buildSrc/src/main/ buildSrc/src/main/
COPY app/core/build.gradle app/core/
COPY app/common/build.gradle app/common/
COPY app/proprietary/build.gradle app/proprietary/
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
description = "AI Document Engine"
requires-python = ">=3.13"
dependencies = [
"cryptography>=44.0.0",
"cryptography>=50.0.0",
"fastapi>=0.116.0",
"pgvector>=0.3.6",
"psycopg[binary,pool]>=3.2",
+151 -151
View File
@@ -41,7 +41,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.1"
version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -52,72 +52,72 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
[[package]]
@@ -473,52 +473,52 @@ wheels = [
[[package]]
name = "cryptography"
version = "49.0.0"
version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
{ url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
]
[[package]]
@@ -538,21 +538,21 @@ wheels = [
[[package]]
name = "datamodel-code-generator"
version = "0.56.0"
version = "0.64.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argcomplete" },
{ name = "black" },
{ name = "black", marker = "sys_platform != 'emscripten'" },
{ name = "genson" },
{ name = "inflect" },
{ name = "isort" },
{ name = "isort", marker = "sys_platform != 'emscripten'" },
{ name = "jinja2" },
{ name = "pydantic" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/7d/7fc2bb3d8946ca45851da3f23497a2c6e252e92558ccbd89d609cf1e13d4/datamodel_code_generator-0.56.0.tar.gz", hash = "sha256:e7c003fb5421b890aabe12f66ae65b57198b04cfe1da7c40810798020835b3a8", size = 837708, upload-time = "2026-04-04T09:46:19.636Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/d2/86c94a2836ed42231653a7ddaefa0a5bc23418167a876bba7376c96b3a35/datamodel_code_generator-0.64.0.tar.gz", hash = "sha256:9c592900a00b20e416494273c22435f5a9aef6ea8c7b9190747522a60497a1cb", size = 1316440, upload-time = "2026-06-14T17:24:50.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" },
{ url = "https://files.pythonhosted.org/packages/23/94/71338e2f0146ac10747a5537b3a1e45256e66b7c229869eb0ee787111b41/datamodel_code_generator-0.64.0-py3-none-any.whl", hash = "sha256:b7cd8bd41a312aa997aec6150670bad781847c5b674f17e4d70e78208a0fb990", size = 374698, upload-time = "2026-06-14T17:24:48.809Z" },
]
[package.optional-dependencies]
@@ -632,7 +632,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=44.0.0" },
{ name = "cryptography", specifier = ">=50.0.0" },
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
{ name = "pgvector", specifier = ">=0.3.6" },
@@ -800,7 +800,7 @@ name = "ffmpeg-python"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "future", marker = "python_full_version < '3.14'" },
{ name = "future" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/5e/d5f9105d59c1325759d838af4e973695081fbbc97182baf73afc78dec266/ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127", size = 21543, upload-time = "2019-07-06T00:19:08.989Z" }
wheels = [
@@ -1346,7 +1346,7 @@ name = "jsonpatch"
version = "1.33"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpointer", marker = "python_full_version < '3.14'" },
{ name = "jsonpointer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" }
wheels = [
@@ -1444,15 +1444,15 @@ name = "langchain-core"
version = "1.4.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch", marker = "python_full_version < '3.14'" },
{ name = "langchain-protocol", marker = "python_full_version < '3.14'" },
{ name = "langsmith", marker = "python_full_version < '3.14'" },
{ name = "packaging", marker = "python_full_version < '3.14'" },
{ name = "pydantic", marker = "python_full_version < '3.14'" },
{ name = "pyyaml", marker = "python_full_version < '3.14'" },
{ name = "tenacity", marker = "python_full_version < '3.14'" },
{ name = "typing-extensions", marker = "python_full_version < '3.14'" },
{ name = "uuid-utils", marker = "python_full_version < '3.14'" },
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" }
wheels = [
@@ -1464,7 +1464,7 @@ name = "langchain-protocol"
version = "0.0.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.14'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
wheels = [
@@ -1476,7 +1476,7 @@ name = "langchain-text-splitters"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core", marker = "python_full_version < '3.14'" },
{ name = "langchain-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" }
wheels = [
@@ -1488,20 +1488,20 @@ name = "langsmith"
version = "0.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "python_full_version < '3.14'" },
{ name = "distro", marker = "python_full_version < '3.14'" },
{ name = "httpx", marker = "python_full_version < '3.14'" },
{ name = "orjson", marker = "python_full_version < '3.14' and platform_python_implementation != 'PyPy'" },
{ name = "packaging", marker = "python_full_version < '3.14'" },
{ name = "pydantic", marker = "python_full_version < '3.14'" },
{ name = "requests", marker = "python_full_version < '3.14'" },
{ name = "requests-toolbelt", marker = "python_full_version < '3.14'" },
{ name = "sniffio", marker = "python_full_version < '3.14'" },
{ name = "typing-extensions", marker = "python_full_version < '3.14'" },
{ name = "uuid-utils", marker = "python_full_version < '3.14'" },
{ name = "websockets", marker = "python_full_version < '3.14'" },
{ name = "xxhash", marker = "python_full_version < '3.14'" },
{ name = "zstandard", marker = "python_full_version < '3.14'" },
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "sniffio" },
{ name = "typing-extensions" },
{ name = "uuid-utils" },
{ name = "websockets" },
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/26/b72987d947278f63ec1e85f01ce85ca7ab2621c7efc0845d4a3a8e5d5dfb/langsmith-0.9.1.tar.gz", hash = "sha256:e5eb905224d156bcece4985285c55b51fffcb06c9353b2c4adb42e1c48b0d05d", size = 4557557, upload-time = "2026-06-23T17:04:23.233Z" }
wheels = [
@@ -2879,7 +2879,7 @@ name = "requests-toolbelt"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests", marker = "python_full_version < '3.14'" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [
@@ -3343,16 +3343,16 @@ name = "voyageai"
version = "0.3.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp", marker = "python_full_version < '3.14'" },
{ name = "aiolimiter", marker = "python_full_version < '3.14'" },
{ name = "ffmpeg-python", marker = "python_full_version < '3.14'" },
{ name = "langchain-text-splitters", marker = "python_full_version < '3.14'" },
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "pillow", marker = "python_full_version < '3.14'" },
{ name = "pydantic", marker = "python_full_version < '3.14'" },
{ name = "requests", marker = "python_full_version < '3.14'" },
{ name = "tenacity", marker = "python_full_version < '3.14'" },
{ name = "tokenizers", marker = "python_full_version < '3.14'" },
{ name = "aiohttp" },
{ name = "aiolimiter" },
{ name = "ffmpeg-python" },
{ name = "langchain-text-splitters" },
{ name = "numpy" },
{ name = "pillow" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "tenacity" },
{ name = "tokenizers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/16/1b46b3cd401e1717a68197c1fe336d7bb4e0a1833f8105e1738f5b1add05/voyageai-0.3.7.tar.gz", hash = "sha256:826cd97f97223f42b5babc5c459c9c80f3a8215ce5c0e007b0b276550f790d24", size = 26485, upload-time = "2025-12-16T18:43:05.26Z" }
wheels = [
+1 -1
View File
@@ -1,7 +1,7 @@
import { beforeAll } from "vitest";
import { setProjectAnnotations } from "@storybook/react-vite";
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
// eslint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
// oxlint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
import * as projectAnnotations from "./preview";
// Include addon-a11y's annotations so its axe checks run under Vitest, not only
+2 -2
View File
@@ -6,7 +6,7 @@ All frontend commands are run from the repository root using [Task](https://task
- `task frontend:build` — production build
- `task frontend:test` — run tests
- `task frontend:test:watch` — run tests in watch mode
- `task frontend:lint` — run ESLint + cycle detection
- `task frontend:lint` — run linting
- `task frontend:typecheck` — run TypeScript type checking
- `task frontend:check` — run typecheck + lint + test
- `task frontend:install` — install npm dependencies
@@ -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/`, ESLint, Prettier — lives at `frontend/` so every app installs
`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs
once and lints with the same config.
## Environment Variables
@@ -8925,7 +8925,7 @@ capturing = "Tasten drücken… (Esc zum Abbrechen)"
change = "Kürzel ändern"
customBadge = "Benutzerdefiniert"
defaultLabel = "Standard: {{shortcut}}"
description = "Tastenkürzel für schnellen Tool-Zugriff anpassen. Klicken Sie auf \"Shortcut ändern\" und drücken Sie eine neue Tastenkombination. Drücken Sie Esc, um abzubrechen."
description = "Tastenkürzel für schnellen Tool-Zugriff anpassen. Klicken Sie auf \"Kürzel ändern\" und drücken Sie eine neue Tastenkombination. Drücken Sie Esc, um abzubrechen."
errorConflict = "Kürzel wird bereits von {{tool}} verwendet."
none = "Nicht zugewiesen"
noShortcut = "Kein Kürzel festgelegt"
@@ -8631,6 +8631,14 @@ text = "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments
[rotate.tooltip.header]
title = "Rotate Settings Overview"
[ruler]
clearAll = "Clear all"
hideAllLabels = "Hide all labels"
hideSmallLabels = "Hide small labels"
physicalValues = "Physical"
scaled = "Scaled"
showAllLabels = "Show all labels"
[sanitize]
desc = "Remove potentially harmful elements from PDF files."
sanitizationResults = "Sanitisation Results"
@@ -8682,6 +8690,32 @@ title = "Sanitise PDF"
tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size"
title = "Adjust page-scale"
[scaleSettings]
activeScale = "Active Scale"
apply = "Apply Scale"
applyCalibration = "Apply Calibration"
calculatedScale = "Calculated scale"
calibrate = "Calibrate"
calibrating = "Calibrating"
calibrationDistanceRequired = "Enter a real-world distance greater than zero"
calibrationInvalid = "Unable to calculate scale from this measurement"
calibrationPaperDistance = "Measured page distance: {{distance}}"
calibrationTitle = "Calibrate Scale"
calibrationTooltip = "Measure a known distance to calculate the scale automatically"
customScale = "Custom Scale"
noneSet = "No custom scale set"
presets = "Preset Scales"
ratio = "Scale Ratio"
ratioHelp = "Ratio: 1 page unit = X real-world units"
ratioPlaceholder = "e.g., 100"
ratioPositive = "Ratio must be greater than zero"
ratioRequired = "Ratio is required"
realDistance = "Real-world distance"
realDistancePlaceholder = "e.g., 5"
reset = "Reset to Defaults"
unit = "Unit"
unitInvalid = "Invalid unit: {{scaleUnit}}"
[scannerEffect]
tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan"
@@ -10670,6 +10704,7 @@ redact = "Redact"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
ruler = "Ruler / Measure"
rulerSettings = "Scale Settings"
save = "Save"
saveAll = "Save All"
saveAs = "Save As"
@@ -3977,6 +3977,7 @@ refresh = "Refresh from server"
remove = "Delete"
removeVersion = "Remove this version"
rename = "Rename"
renamed = "Renamed"
renameFolder = "Rename folder"
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
save = "Save"
@@ -4079,9 +4080,7 @@ count = "Files"
folder = "Folder"
labels = "Labels"
modified = "Modified"
name = "Name"
size = "Size"
toolHistoryAtVersion = "Cumulative tool chain"
totalSize = "Total size"
type = "Type"
versionHistory = "Version journey"
@@ -9556,6 +9555,14 @@ text = "Rotate your PDF pages clockwise or counterclockwise in 90-degree increme
[rotate.tooltip.header]
title = "Rotate Settings Overview"
[ruler]
clearAll = "Clear all"
hideAllLabels = "Hide all labels"
hideSmallLabels = "Hide small labels"
physicalValues = "Physical"
scaled = "Scaled"
showAllLabels = "Show all labels"
[sanitize]
desc = "Remove potentially harmful elements from PDF files."
sanitizationResults = "Sanitization Results"
@@ -9607,6 +9614,32 @@ tags = "clean,secure,safe,remove-threats"
tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size"
title = "Adjust page-scale"
[scaleSettings]
activeScale = "Active Scale"
apply = "Apply Scale"
applyCalibration = "Apply Calibration"
calculatedScale = "Calculated scale"
calibrate = "Calibrate"
calibrating = "Calibrating"
calibrationDistanceRequired = "Enter a real-world distance greater than zero"
calibrationInvalid = "Unable to calculate scale from this measurement"
calibrationPaperDistance = "Measured page distance: {{distance}}"
calibrationTitle = "Calibrate Scale"
calibrationTooltip = "Measure a known distance to calculate the scale automatically"
customScale = "Custom Scale"
noneSet = "No custom scale set"
presets = "Preset Scales"
ratio = "Scale Ratio"
ratioHelp = "Ratio: 1 page unit = X real-world units"
ratioPlaceholder = "e.g., 100"
ratioPositive = "Ratio must be greater than zero"
ratioRequired = "Ratio is required"
realDistance = "Real-world distance"
realDistancePlaceholder = "e.g., 5"
reset = "Reset to Defaults"
unit = "Unit"
unitInvalid = "Invalid unit: {{scaleUnit}}"
[scannerEffect]
tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan"
@@ -11625,6 +11658,7 @@ redact = "Redact"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
ruler = "Ruler / Measure"
rulerSettings = "Scale Settings"
save = "Save"
saveAll = "Save All"
saveAs = "Save As"
@@ -29,7 +29,7 @@ customTextTooltip = "Formato personalizado opcional para os números de página.
delete = "Apagar"
details = "Detalhes"
discardChanges = "Descartar alterações"
download = "Baixar (JSON)"
download = "Baixar"
downloadPdf = "Baixar PDF"
downloadUnavailable = "Download indisponível para este item"
edit = "Editar"
@@ -3574,7 +3574,7 @@ deleteAll = "Excluir tudo"
deleteSelected = "Apagar Selecionados"
deselectAll = "Desselecionar Tudo"
details = "Detalhes do arquivo"
download = "Baixar (JSON)"
download = "Baixar"
downloadSelected = "Baixar selecionados"
dropFilesHere = "Solte os arquivos aqui"
fileFormat = "Formato"
+1 -1
View File
@@ -13,7 +13,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// tsx/node16 can't resolve the @portal alias here, so import by relative .ts path.
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import {
buildManifest,
type CategoryMap,
+1 -1
View File
@@ -8,5 +8,5 @@
// sync-portal-docs.mts imports the shared transform by its .ts path (run via tsx).
"allowImportingTsExtensions": true
},
"include": ["./**/*.ts", "./**/*.mts"]
"include": ["./**/*.ts", "./**/*.mts", "../../oxlint.config.ts"]
}
@@ -32,9 +32,9 @@ import {
prepaidSnapshotFromWallet,
} from "@app/components/shared/config/configSections/usageMeters";
// Relative (not @app/*) so the co-located CSS + sibling component resolve directly.
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./Payg.css";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import SpendCapControl from "./SpendCapControl";
import { useTranslation } from "react-i18next";
import type { Wallet } from "@app/hooks/useWallet";
@@ -33,13 +33,13 @@ import LockIcon from "@mui/icons-material/LockOutlined";
import { useTranslation } from "react-i18next";
import { useRenderCount } from "@app/hooks/useRenderCount";
import { useWallet } from "@app/hooks/useWallet";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./Payg.css";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./PaygFree.css";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import UpgradeModal from "./UpgradeModal";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import { DocHelp } from "./Payg";
import {
FreeMeterPanel,
@@ -12,7 +12,7 @@ import {
DEFAULT_CAP_PRESETS,
SpendCapControl as SharedSpendCapControl,
} from "@app/billing";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./SpendCapControl.css";
export { DEFAULT_CAP_PRESETS };
@@ -25,9 +25,9 @@ import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded";
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
import CheckCircleIcon from "@mui/icons-material/CheckCircleRounded";
import { useTranslation } from "react-i18next";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import "./UpgradeModal.css";
// eslint-disable-next-line no-restricted-imports
// oxlint-disable-next-line no-restricted-imports
import SpendCapControl from "./SpendCapControl";
/**
@@ -4,7 +4,7 @@
* The cloud/ layer is the SHARED hosted experience consumed by BOTH the saas
* (web) and desktop (Tauri) leaves, so it must stay platform-portable: it can't
* read {@code import.meta.env}, {@code window.location} or web storage directly
* (the cloud ESLint guardrail enforces this). The PAYG dev-preview route
* (the cloud oxlint guardrail enforces this). The PAYG dev-preview route
* ({@code /dev/payg-preview}) is a saas-only local-design affordance that
* synthesises a wallet from {@code localStorage} when the real backend isn't
* mounted — all three of those banned reads. {@link useWallet} reaches that
+23
View File
@@ -1,4 +1,27 @@
import apiClient from "@app/services/apiClient";
import { getSimulatedAppConfig } from "@app/testing/serverExperienceSimulations";
import type { AppConfig } from "@app/types/appConfig";
/** Unauthenticated and unreachable both mean "assume login is on". */
export const DEFAULT_APP_CONFIG: AppConfig = { enableLogin: true };
export async function fetchAppConfig(): Promise<AppConfig> {
const simulated = getSimulatedAppConfig();
if (simulated) return simulated;
try {
const response = await apiClient.get<AppConfig>(
"/api/v1/config/app-config",
{ suppressErrorToast: true, skipAuthRedirect: true },
);
return response.data;
} catch (error) {
// 401 is an answer, not a failure: the app runs unauthenticated.
if ((error as { response?: { status?: number } })?.response?.status === 401)
return DEFAULT_APP_CONFIG;
throw error;
}
}
export interface FooterInfo {
analyticsEnabled?: boolean;
@@ -974,48 +974,57 @@
like a commit graph; clicking a row's summary toggles its expanded
detail (cumulative tool chain, full meta). Long chains (> 6) collapse
the middle behind a "Show N earlier versions" button. */
/* Version Timeline styling */
.files-page-details-version-timeline {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.55rem 0.7rem 0.7rem;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: 0.5rem;
gap: 0.6rem;
padding: 0.5rem 0;
background: transparent;
}
.files-page-details-version-timeline-label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
gap: 0.4rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--c-text-subtle);
margin-bottom: 0.85rem;
}
.files-page-details-version-timeline-count {
margin-left: auto;
font-weight: 600;
color: var(--c-text-muted, var(--c-text-subtle));
color: var(--c-primary);
text-transform: none;
letter-spacing: 0;
}
.files-page-details-version-timeline-list {
list-style: none;
margin: 0;
padding: 0;
list-style: none !important;
margin: 0 !important;
padding: 1.1rem 0 0 !important;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.files-page-details-version-timeline-row,
.files-page-details-version-timeline-ellipsis {
display: flex;
gap: 0.6rem;
padding: 0.15rem 0;
gap: 0.85rem;
padding: 0;
position: relative;
list-style: none !important;
}
.files-page-details-version-timeline-row::before,
.files-page-details-version-timeline-ellipsis::before {
content: none !important;
}
.files-page-details-version-timeline-rail {
@@ -1023,29 +1032,30 @@
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 0.8rem;
padding-top: 0.45rem;
width: 1rem;
padding-top: 0.85rem;
}
.files-page-details-version-timeline-rail-dot {
width: 0.55rem;
height: 0.55rem;
width: 0.65rem;
height: 0.65rem;
border-radius: 50%;
background: var(--c-bg-raised);
border: 2px solid var(--c-border-strong, var(--c-border-subtle));
z-index: 1;
z-index: 2;
flex-shrink: 0;
transition: all 0.2s ease;
}
.files-page-details-version-timeline-rail-dot.is-active {
background: var(--c-primary);
border-color: var(--c-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 25%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 30%, transparent);
}
.files-page-details-version-timeline-rail-dot.is-ellipsis {
width: 0.35rem;
height: 0.35rem;
width: 0.4rem;
height: 0.4rem;
background: var(--c-text-subtle);
border-color: transparent;
}
@@ -1054,8 +1064,8 @@
width: 2px;
flex: 1;
background: var(--c-border-subtle);
min-height: 0.6rem;
margin-top: 2px;
min-height: 1.25rem;
margin-top: 6px;
}
.files-page-details-version-timeline-body {
@@ -1063,64 +1073,65 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.25rem 0.3rem 0.4rem;
border-radius: 0.35rem;
transition: background-color 0.12s ease;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-radius: 0.65rem;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
}
.files-page-details-version-timeline-body:hover {
border-color: var(--c-border-strong, var(--c-border-subtle));
}
.files-page-details-version-timeline-row.is-active
.files-page-details-version-timeline-body {
background: color-mix(in srgb, var(--c-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--c-primary) 40%, transparent);
background: color-mix(in srgb, var(--c-primary) 5%, var(--c-surface));
box-shadow: 0 2px 6px color-mix(in srgb, var(--c-primary) 12%, transparent);
}
.files-page-details-version-timeline-summary {
.files-page-details-version-timeline-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.files-page-details-version-timeline-tool-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--c-text);
}
.files-page-details-version-timeline-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-top: 0.1rem;
}
.files-page-details-version-timeline-expand-btn {
appearance: none;
background: none;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
}
.files-page-details-version-timeline-summary:hover
.files-page-details-version-timeline-chevron {
.files-page-details-version-timeline-expand-btn:hover span {
color: var(--c-text);
}
.files-page-details-version-timeline-delta {
font-size: 0.82rem;
color: var(--c-text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
}
.files-page-details-version-timeline-delta.is-origin {
font-weight: 400;
color: var(--c-text-subtle);
font-style: italic;
}
.files-page-details-version-timeline-delta-plus {
color: var(--c-primary);
font-weight: 700;
}
.files-page-details-version-timeline-spacer {
flex: 1;
}
.files-page-details-version-timeline-chevron {
color: var(--c-text-subtle);
transition: transform 0.15s ease;
@@ -1128,22 +1139,14 @@
.files-page-details-version-timeline-chevron.is-expanded {
transform: rotate(180deg);
color: var(--c-text);
}
.files-page-details-version-timeline-meta-line {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.7rem;
color: var(--c-text-subtle);
color: var(--c-primary);
}
.files-page-details-version-timeline-expanded {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.45rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--c-border-subtle);
}
@@ -1166,10 +1169,10 @@
appearance: none;
background: none;
border: 1px dashed var(--c-border-subtle);
border-radius: 0.3rem;
padding: 0.25rem 0.5rem;
border-radius: 0.4rem;
padding: 0.35rem 0.7rem;
margin: 0.1rem 0;
font-size: 0.72rem;
font-size: 0.75rem;
color: var(--c-text-subtle);
cursor: pointer;
text-align: left;
@@ -1181,7 +1184,7 @@
.files-page-details-version-timeline-ellipsis-btn:hover,
.files-page-details-version-timeline-collapse-btn:hover {
color: var(--c-text);
border-color: var(--c-border-strong, var(--c-text-subtle));
border-color: var(--c-primary);
}
.files-page-details-version-timeline-collapse-btn {
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Center, Loader, Modal, Text } from "@mantine/core";
import HistoryIcon from "@mui/icons-material/History";
import { Center, Group, Loader, Modal, Text } from "@mantine/core";
import type { FileId } from "@app/types/file";
import type { StirlingFileStub } from "@app/types/fileContext";
@@ -99,7 +100,17 @@ export function VersionHistoryModal({
onClose={onClose}
centered
size="md"
title={t("filesPage.field.versionHistory", "Version journey")}
title={
<Group gap="xs" align="center">
<HistoryIcon
fontSize="small"
style={{ color: "var(--c-accent-fg, var(--c-primary))" }}
/>
<Text fw={600} size="md" c="var(--c-text)">
{t("filesPage.field.versionHistory", "Version journey")}
</Text>
</Group>
}
>
{loading ? (
<Center py="lg">
@@ -111,6 +122,7 @@ export function VersionHistoryModal({
currentId={file.id}
onAddToWorkspace={handleAddToWorkspace}
onRemove={handleRemove}
hideHeader
/>
) : (
<Text size="sm" c="dimmed" py="sm">
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge, Menu } from "@mantine/core";
import { Badge, Group, Menu, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
@@ -8,16 +8,14 @@ import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import HistoryIcon from "@mui/icons-material/History";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { FileId, ToolOperation } from "@app/types/file";
import { ToolId } from "@app/types/toolId";
import { StirlingFileStub } from "@app/types/fileContext";
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
import { downloadFileFromStorage } from "@app/utils/downloadUtils";
import ToolChain from "@app/components/shared/ToolChain";
/** Small label/value row; shared with FileDetailsPanel. */
/** Small label/value row with crisp flex alignment and colon separation. */
export function DetailField({
label,
value,
@@ -26,9 +24,31 @@ export function DetailField({
value: string;
}) {
return (
<div className="files-page-details-field">
<span className="files-page-details-field-label">{label}</span>
<span className="files-page-details-field-value">{value}</span>
<div
className="files-page-details-field"
style={{
display: "flex",
gap: "0.5rem",
alignItems: "baseline",
wordBreak: "break-all",
}}
>
<span
className="files-page-details-field-label"
style={{
fontWeight: 500,
color: "var(--c-text-subtle)",
flexShrink: 0,
}}
>
{label}:
</span>
<span
className="files-page-details-field-value"
style={{ fontWeight: 600, color: "var(--c-text)" }}
>
{value}
</span>
</div>
);
}
@@ -60,7 +80,7 @@ export interface VersionTimelineProps {
hideHeader?: boolean;
}
/** Version timeline with per-row tool deltas and collapse-when-long. */
/** Clean, spacious version timeline with minimal clutter. */
export function VersionTimeline({
chain,
currentId,
@@ -69,7 +89,6 @@ export function VersionTimeline({
hideHeader = false,
}: VersionTimelineProps) {
const { t } = useTranslation();
const [expandedIds, setExpandedIds] = useState<Set<FileId>>(new Set());
const [showAllCollapsed, setShowAllCollapsed] = useState(false);
// Newest-first ordering.
@@ -113,15 +132,6 @@ export function VersionTimeline({
return [...head, { kind: "ellipsis", hidden }, ...tail];
}, [collapsible, showAllCollapsed, ordered]);
const toggleExpand = (id: FileId) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div className="files-page-details-version-timeline">
{!hideHeader && (
@@ -135,7 +145,10 @@ export function VersionTimeline({
</span>
</div>
)}
<ol className="files-page-details-version-timeline-list">
<ul
className="files-page-details-version-timeline-list"
style={{ listStyle: "none", padding: 0, margin: 0 }}
>
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
if (row.kind === "ellipsis") {
@@ -143,6 +156,7 @@ export function VersionTimeline({
<li
key="ellipsis"
className="files-page-details-version-timeline-ellipsis"
style={{ listStyle: "none" }}
>
<div className="files-page-details-version-timeline-rail">
<span className="files-page-details-version-timeline-rail-dot is-ellipsis" />
@@ -152,7 +166,7 @@ export function VersionTimeline({
</div>
<Button
variant="tertiary"
className="files-page-details-version-timeline-ellipsis-btn"
size="sm"
onClick={() => setShowAllCollapsed(true)}
>
{t(
@@ -166,15 +180,18 @@ export function VersionTimeline({
}
const v = row.version;
const isActive = v.id === currentId;
const isExpanded = expandedIds.has(v.id);
const prior = byVersionNumber.get((v.versionNumber ?? 1) - 1) ?? null;
const delta = deltaToolFor(v, prior);
const isOriginal = (v.versionNumber ?? 1) === 1;
const nameChanged = prior ? prior.name !== v.name : false;
return (
<li
key={v.id}
className={`files-page-details-version-timeline-row${
isActive ? " is-active" : ""
}`}
style={{ listStyle: "none" }}
>
<div className="files-page-details-version-timeline-rail">
<span
@@ -187,131 +204,119 @@ export function VersionTimeline({
)}
</div>
<div className="files-page-details-version-timeline-body">
<Button
variant="tertiary"
className="files-page-details-version-timeline-summary"
onClick={() => toggleExpand(v.id)}
aria-expanded={isExpanded}
leftSection={
<Badge
size="xs"
variant={isActive ? "filled" : "outline"}
color="blue"
>
{/* Header row: Version Badge, Action Title + Menu */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
width: "100%",
}}
>
<Group
gap="sm"
align="center"
style={{ flex: 1, minWidth: 0, flexWrap: "nowrap" }}
>
<Badge size="xs" variant={isActive ? "filled" : "light"}>
v{v.versionNumber ?? 1}
</Badge>
}
rightSection={
<KeyboardArrowDownIcon
className={`files-page-details-version-timeline-chevron${
isExpanded ? " is-expanded" : ""
}`}
fontSize="small"
/>
}
>
{delta ? (
<span className="files-page-details-version-timeline-delta">
<ToolLabel toolId={delta.toolId} />
</span>
) : (
<span className="files-page-details-version-timeline-delta is-origin">
{t("filesPage.versionOrigin", "Original upload")}
</span>
)}
</Button>
<div className="files-page-details-version-timeline-meta-line">
<span>{formatFileSize(v.size)}</span>
{v.lastModified ? (
<>
<span>·</span>
<span>
{getFileDate({ lastModified: v.lastModified })}
</span>
</>
) : null}
{/* Kebab on every row - the original/active version also
needs download + open-in-workspace. */}
<span className="files-page-details-version-timeline-spacer" />
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon
variant="tertiary"
size="sm"
aria-label={t(
"filesPage.versionActions",
"Version actions",
)}
onClick={(e) => e.stopPropagation()}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={() => onAddToWorkspace([v.id])}
>
{t(
"filesPage.openVersionInWorkspace",
"Open in workspace",
)}
</Menu.Item>
<Menu.Item
leftSection={<DownloadIcon fontSize="small" />}
onClick={() => {
void downloadFileFromStorage(v);
}}
>
{t(
"filesPage.downloadVersion",
"Download this version",
)}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={() => onRemove([v.id])}
>
{t("filesPage.removeVersion", "Remove this version")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
{isExpanded && (
// Filename + full cumulative tool chain.
<div className="files-page-details-version-timeline-expanded">
<DetailField
label={t("filesPage.field.name", "Name")}
value={v.name}
/>
{v.toolHistory && v.toolHistory.length > 0 && (
<div className="files-page-details-version-timeline-toolchain">
<span className="files-page-details-version-timeline-toolchain-label">
{t(
"filesPage.field.toolHistoryAtVersion",
"Cumulative tool chain",
<Text
fw={600}
size="sm"
truncate
style={{ color: "var(--c-text)" }}
>
{delta ? (
<ToolLabel toolId={delta.toolId} />
) : (
t("filesPage.versionOrigin", "Original upload")
)}
</Text>
</Group>
{!isActive && (
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon
variant="tertiary"
size="sm"
aria-label={t(
"filesPage.versionActions",
"Version actions",
)}
</span>
<ToolChain
toolChain={v.toolHistory}
displayStyle="badges"
size="xs"
/>
</div>
)}
</div>
onClick={(e) => e.stopPropagation()}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={() => onAddToWorkspace([v.id])}
>
{t(
"filesPage.openVersionInWorkspace",
"Open in workspace",
)}
</Menu.Item>
<Menu.Item
leftSection={<DownloadIcon fontSize="small" />}
onClick={() => {
void downloadFileFromStorage(v);
}}
>
{t(
"filesPage.downloadVersion",
"Download this version",
)}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={() => onRemove([v.id])}
>
{t("filesPage.removeVersion", "Remove this version")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</div>
{/* Quiet Meta Line: File Size · Date */}
<Text size="xs" c="dimmed" style={{ marginTop: "0.15rem" }}>
{formatFileSize(v.size)}
{v.lastModified && (
<> · {getFileDate({ lastModified: v.lastModified })}</>
)}
</Text>
{/* Show filename ONLY if original upload or if name changed */}
{(isOriginal || nameChanged) && (
<Text
size="xs"
c="dimmed"
style={{ wordBreak: "break-all", marginTop: "0.2rem" }}
>
{nameChanged
? `${t("filesPage.renamed", "Renamed")}: `
: `${t("filesPage.file", "File")}: `}
<span style={{ color: "var(--c-text)", fontWeight: 500 }}>
{v.name}
</span>
</Text>
)}
</div>
</li>
);
})}
</ol>
</ul>
{collapsible && showAllCollapsed && (
<Button
variant="tertiary"
className="files-page-details-version-timeline-collapse-btn"
size="sm"
onClick={() => setShowAllCollapsed(false)}
>
{t("filesPage.versionCollapse", "Collapse middle versions")}
@@ -686,7 +686,6 @@ export class InsertFilesCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
private fileDataMap = new Map<FileId, ArrayBuffer>(); // Store file data for thumbnail generation
private originalProcessedFile: any = null; // Store original ProcessedFile for undo
private insertedFileMap = new Map<FileId, File>(); // Store inserted files for export
constructor(
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react";
import {
BulkRotateCommand,
DeletePagesCommand,
DOMCommand,
PageBreakCommand,
ReorderPagesCommand,
SplitCommand,
@@ -24,7 +25,7 @@ interface UsePageEditorCommandsParams {
selectedPageIds: string[];
setSelectedPageIds: (ids: string[]) => void;
getPageNumbersFromIds: (pageIds: string[]) => number[];
executeCommandWithTracking: (command: any) => void;
executeCommandWithTracking: (command: DOMCommand) => void;
updateFileOrderFromPages: (pages: PDFPage[]) => void;
actions: FileActions;
selectors: FileSelectors;
@@ -145,10 +146,8 @@ export const usePageEditorCommands = ({
[executeCommandWithTracking, setSplitPositions],
);
const executeCommand = useCallback((command: any) => {
if (command && typeof command.execute === "function") {
command.execute();
}
const executeCommand = useCallback((command: { execute: () => void }) => {
command.execute();
}, []);
const handleRotate = useCallback(
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { UndoManager } from "@app/components/pageEditor/commands/pageCommands";
import {
DOMCommand,
UndoManager,
} from "@app/components/pageEditor/commands/pageCommands";
interface UseUndoManagerStateParams {
setHasUnsavedChanges: (dirty: boolean) => void;
@@ -29,7 +32,7 @@ export const useUndoManagerState = ({
}, [updateUndoRedoState]);
const executeCommandWithTracking = useCallback(
(command: any) => {
(command: DOMCommand) => {
undoManagerRef.current.executeCommand(command);
setHasUnsavedChanges(true);
},
@@ -1,6 +1,6 @@
import React from "react";
import { addCollection, Icon } from "@iconify/react";
import iconSet from "../../../assets/material-symbols-icons.json"; // eslint-disable-line no-restricted-imports -- Outside app paths
import iconSet from "../../../assets/material-symbols-icons.json"; // oxlint-disable-line no-restricted-imports -- Outside app paths
// Load icons synchronously at import time - guaranteed to be ready on first render
let iconsLoaded = false;
@@ -138,7 +138,7 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
const translationPrefixes = getTranslationPrefixesForNavKey(item.key);
const translationContent = translationPrefixes.flatMap((prefix) =>
flattenTranslationStrings(
t(prefix, { returnObjects: true, defaultValue: {} } as any),
t(prefix, { returnObjects: true, defaultValue: {} }),
),
);
@@ -12,7 +12,7 @@ import {
} from "@mantine/core";
import { isAxiosError } from "axios";
import apiClient from "@app/services/apiClient";
import frontendLicenses from "../../../../../assets/3rdPartyLicenses.json"; // eslint-disable-line no-restricted-imports -- asset lives outside @app alias root
import frontendLicenses from "../../../../../assets/3rdPartyLicenses.json"; // oxlint-disable-line no-restricted-imports -- asset lives outside @app alias root
interface Dependency {
moduleName?: string;
@@ -111,8 +111,7 @@ export const useFileItemDragDrop = ({
if (!element) return;
const rect = element.getBoundingClientRect();
const clientY =
(source as any).element?.getBoundingClientRect().top || 0;
const clientY = source.element?.getBoundingClientRect().top || 0;
const midpoint = rect.top + rect.height / 2;
setDropPosition(clientY < midpoint ? "below" : "above");
@@ -121,7 +120,10 @@ export const useFileItemDragDrop = ({
setIsDragOver(false);
const dropPos = dropPositionRef.current;
setDropPosition("below");
const sourceData = source.data as any;
const sourceData = source.data as {
type?: string;
fromIndex?: number;
};
if (sourceData?.type === "file-item") {
const fromIndex = sourceData.fromIndex as number;
let toIndex = indexRef.current;
@@ -14,9 +14,9 @@ import ButtonSelector from "@app/components/shared/ButtonSelector";
interface BookletImpositionSettingsProps {
parameters: BookletImpositionParameters;
onParameterChange: (
key: keyof BookletImpositionParameters,
value: any,
onParameterChange: <K extends keyof BookletImpositionParameters>(
key: K,
value: BookletImpositionParameters[K],
) => void;
disabled?: boolean;
}
@@ -214,7 +214,10 @@ const BookletImpositionSettings = ({
)}
value={parameters.gutterSize}
onChange={(value) =>
onParameterChange("gutterSize", value || 12)
onParameterChange(
"gutterSize",
typeof value === "number" ? value : 12,
)
}
min={6}
max={72}
@@ -2,13 +2,14 @@ import React from "react";
import { Flex, Text, Divider } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import { TooltipTip } from "@app/types/tips";
export interface ToolWorkflowTitleProps {
title: string;
description?: string;
tooltip?: {
content?: React.ReactNode;
tips?: any[];
tips?: TooltipTip[];
header?: {
title: string;
logo?: React.ReactNode;
@@ -2,7 +2,10 @@ import { Box } from "@mantine/core";
import ToolButton from "@app/components/tools/toolPicker/ToolButton";
import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader";
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
import {
getSubcategoryLabel,
type ToolRegistryEntry,
} from "@app/data/toolsTaxonomy";
import { TFunction } from "i18next";
import { SubcategoryGroup } from "@app/hooks/useToolSections";
import { ToolId } from "@app/types/toolId";
@@ -15,7 +18,10 @@ export const renderToolButtons = (
onSelect: (id: ToolId) => void,
showSubcategoryHeader: boolean = true,
disableNavigation: boolean = false,
searchResults?: Array<{ item: [string, any]; matchedText?: string }>,
searchResults?: Array<{
item: [ToolId, ToolRegistryEntry];
matchedText?: string;
}>,
hasStars: boolean = false,
) => {
// Create a map of matched text for quick lookup
@@ -1,10 +1,4 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import React, { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Box, Center, Text, Stack } from "@mantine/core";
import { Button } from "@app/ui/Button";
@@ -43,109 +37,17 @@ import { useViewerWorkbenchBarButtons } from "@app/components/viewer/useViewerWo
import { StampPlacementOverlay } from "@app/components/viewer/StampPlacementOverlay";
import {
RulerOverlay,
type PageMeasureScales,
type PageScaleInfo,
type ViewportScale,
type RulerOverlayHandle,
} from "@app/components/viewer/RulerOverlay";
import type { PDFDict, PDFNumber } from "@cantoo/pdf-lib";
import { useWheelZoom } from "@app/hooks/useWheelZoom";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand";
import { useMeasurementManager } from "@app/hooks/useMeasurementManager";
import { ScaleCalibrationDialog } from "@app/components/viewer/ScaleCalibrationDialog";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import { alert } from "@app/components/toast";
// ─── Measure dictionary extraction ────────────────────────────────────────────
async function extractPageMeasureScales(
file: Blob,
): Promise<PageMeasureScales | null> {
try {
const {
PDFDocument,
PDFDict,
PDFName,
PDFArray,
PDFNumber,
PDFString,
PDFHexString,
} = await import("@cantoo/pdf-lib");
const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), {
ignoreEncryption: true,
});
// Parse a Measure dict into a MeasureScale, or return null if malformed.
const parseScale = (measureObj: unknown) => {
if (!(measureObj instanceof PDFDict)) return null;
// @cantoo/pdf-lib ships without individual .d.ts files so instanceof can't narrow `unknown`
const m = measureObj as PDFDict;
const rObj = m.lookup(PDFName.of("R"));
const ratioLabel =
rObj instanceof PDFString || rObj instanceof PDFHexString
? rObj.decodeText()
: "";
// D = distance array, X = x-axis fallback
let fmtArray = m.lookup(PDFName.of("D"));
if (!(fmtArray instanceof PDFArray)) fmtArray = m.lookup(PDFName.of("X"));
if (!(fmtArray instanceof PDFArray)) return null;
const firstFmt = fmtArray.lookup(0);
if (!(firstFmt instanceof PDFDict)) return null;
const cObj = firstFmt.lookup(PDFName.of("C"));
const uObj = firstFmt.lookup(PDFName.of("U"));
if (!(cObj instanceof PDFNumber) || cObj.asNumber() <= 0) return null;
const unit =
uObj instanceof PDFString || uObj instanceof PDFHexString
? uObj.decodeText()
: "units";
return { factor: cObj.asNumber(), unit, ratioLabel };
};
const result: PageMeasureScales = new Map();
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
const page = pdfDoc.getPage(i);
const pageHeight = page.getHeight();
const pageNode = page.node as unknown as PDFDict;
const viewports: ViewportScale[] = [];
// Spec-conformant: /VP array — each viewport can have its own scale and BBox
const vpObj = pageNode.lookup(PDFName.of("VP"));
if (vpObj instanceof PDFArray) {
for (let j = 0; j < vpObj.size(); j++) {
const vpEntry = vpObj.lookup(j);
if (!(vpEntry instanceof PDFDict)) continue;
const scale = parseScale(vpEntry.lookup(PDFName.of("Measure")));
if (!scale) continue;
let bbox: ViewportScale["bbox"] = null;
const bboxObj = vpEntry.lookup(PDFName.of("BBox"));
if (bboxObj instanceof PDFArray && bboxObj.size() >= 4) {
bbox = [
(bboxObj.lookup(0) as PDFNumber).asNumber(),
(bboxObj.lookup(1) as PDFNumber).asNumber(),
(bboxObj.lookup(2) as PDFNumber).asNumber(),
(bboxObj.lookup(3) as PDFNumber).asNumber(),
];
}
viewports.push({ bbox, scale });
}
}
// Fallback: /Measure directly on page (non-conforming but seen in the wild)
if (viewports.length === 0) {
const scale = parseScale(pageNode.lookup(PDFName.of("Measure")));
if (scale) viewports.push({ bbox: null, scale });
}
if (viewports.length > 0)
result.set(i, { viewports, pageHeight } satisfies PageScaleInfo);
}
return result.size > 0 ? result : null;
} catch {
return null;
}
}
// ──────────────────────────────────────────────────────────────────────────────
export interface EmbedPdfViewerProps {
@@ -378,6 +280,12 @@ const EmbedPdfViewerContent = ({
return null;
}, [previewFile, activeFiles, activeFileId]);
// Namespaced identifier for form-fill state; keep this aligned with FormFill.
const currentFileId = React.useMemo(
() => getFormFillFileId(currentFile),
[currentFile],
);
// Stable id — avoids blob URL churn when FileContext recreates file objects each render.
const currentFileStableId =
currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null;
@@ -1148,28 +1056,37 @@ const EmbedPdfViewerContent = ({
};
}, [applyChanges, setApplyChanges]);
// Ruler / measurement tool state
const [isRulerActive, setIsRulerActive] = useState(false);
const [pageMeasureScales, setPageMeasureScales] =
useState<PageMeasureScales | null>(null);
// Ruler / measurement tool state is handled by the dedicated hook.
const rulerOverlayRef = useRef<RulerOverlayHandle | null>(null);
useEffect(() => {
const file = effectiveFile?.file;
if (!file) {
setPageMeasureScales(null);
return;
}
let cancelled = false;
extractPageMeasureScales(file).then((scales) => {
if (!cancelled) setPageMeasureScales(scales);
});
return () => {
cancelled = true;
};
}, [effectiveFile]);
const {
isRulerActive,
setIsRulerActive,
pageMeasureScales,
customScale,
handleSetCustomScale,
isScaleCalibrationActive,
scaleCalibrationMeasurement,
startScaleCalibration,
cancelScaleCalibration,
handleScaleCalibrationMeasurement,
applyScaleCalibration,
} = useMeasurementManager({
currentFile,
effectiveFile,
rulerOverlayRef,
});
// Register workbench bar buttons for the viewer
useViewerWorkbenchBarButtons(isRulerActive, setIsRulerActive);
useViewerWorkbenchBarButtons(
isRulerActive,
setIsRulerActive,
customScale,
handleSetCustomScale,
isScaleCalibrationActive,
startScaleCalibration,
cancelScaleCalibration,
);
// Auto-fetch form fields when a PDF is loaded in the viewer.
// In normal viewer mode, this uses PDFium WASM (frontend-only).
@@ -1178,10 +1095,6 @@ const EmbedPdfViewerContent = ({
const formFillProviderRef = useRef(isFormFillToolActive);
// Generate a unique identifier for the current file to detect file changes
const currentFileId = React.useMemo(() => {
return getFormFillFileId(currentFile);
}, [currentFile]);
useEffect(() => {
const fileChanged = currentFileId !== formFillFileIdRef.current;
const providerChanged =
@@ -1358,11 +1271,22 @@ const EmbedPdfViewerContent = ({
signatureConfig={signatureConfig}
/>
<RulerOverlay
ref={rulerOverlayRef}
containerRef={pdfContainerRef}
isActive={isRulerActive}
pageMeasureScales={pageMeasureScales}
customScale={customScale}
isCalibrationActive={isScaleCalibrationActive}
onCalibrationMeasure={handleScaleCalibrationMeasurement}
/>
</Box>
<ScaleCalibrationDialog
opened={!!scaleCalibrationMeasurement}
measurement={scaleCalibrationMeasurement}
defaultUnit={customScale?.unit ?? "m"}
onApplyScale={applyScaleCalibration}
onClose={cancelScaleCalibration}
/>
</>
)}
@@ -7,7 +7,7 @@ import React, {
} from "react";
import { createPluginRegistration } from "@embedpdf/core";
import type { PluginRegistry } from "@embedpdf/core";
import { EmbedPDF } from "@embedpdf/core/react";
import { EmbedPDF, useDocumentState } from "@embedpdf/core/react";
import { usePdfiumEngine } from "@embedpdf/engines/react";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -147,6 +147,59 @@ interface LocalEmbedPDFProps {
signatureOverlayApiRef?: React.RefObject<SignatureOverlayAPI | null>;
}
interface ViewerPageContainerProps {
documentId: string;
pageIndex: number;
width: number;
height: number;
children: React.ReactNode;
}
function normalizePageRotation(rotation: number | null | undefined): number {
const value =
typeof rotation === "number" && Number.isFinite(rotation) ? rotation : 0;
return ((Math.round(value) % 4) + 4) % 4;
}
function ViewerPageContainer({
documentId,
pageIndex,
width,
height,
children,
}: ViewerPageContainerProps) {
const documentState = useDocumentState(documentId);
const pageRotation = normalizePageRotation(
documentState?.document?.pages?.[pageIndex]?.rotation,
);
return (
<div
data-page-index={pageIndex}
data-page-width={width}
data-page-height={height}
data-page-rotation={pageRotation}
style={{
width,
height,
position: "relative",
overflow: "hidden", // clip overlays (buttons, fields) that extend beyond the page rect
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
>
{children}
</div>
);
}
export function LocalEmbedPDF({
file,
url,
@@ -1023,25 +1076,11 @@ export function LocalEmbedPDF({
documentId={documentId}
pageIndex={pageIndex}
>
<div
data-page-index={pageIndex}
data-page-width={width}
data-page-height={height}
style={{
width,
height,
position: "relative",
overflow: "hidden", // clip overlays (buttons, fields) that extend beyond the page rect
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
<ViewerPageContainer
documentId={documentId}
pageIndex={pageIndex}
width={width}
height={height}
>
<div
style={{
@@ -1173,7 +1212,7 @@ export function LocalEmbedPDF({
onSelect={setSelectedSignatureId}
/>
)}
</div>
</ViewerPageContainer>
</PagePointerProvider>
</Rotate>
);
@@ -21,7 +21,8 @@ export function RotateAPIBridge() {
function RotateAPIBridgeInner({ documentId }: { documentId: string }) {
const { provides: rotate, rotation } = useRotate(documentId);
const { registerBridge } = useViewer();
const { registerBridge, triggerImmediateRotationUpdate } = useViewer();
const isRotateAvailable = rotate !== null;
// Keep rotate ref updated to avoid re-running effect when object reference changes
const rotateRef = useRef(rotate);
@@ -29,6 +30,18 @@ function RotateAPIBridgeInner({ documentId }: { documentId: string }) {
rotateRef.current = rotate;
}, [rotate]);
// Use the plugin event directly so overlays can update in the same turn as
// the page rotation, instead of waiting for this bridge to re-render.
useEffect(() => {
const currentRotate = rotateRef.current;
if (!currentRotate) return;
triggerImmediateRotationUpdate(currentRotate.getRotation());
return currentRotate.onRotateChange((nextRotation) => {
triggerImmediateRotationUpdate(nextRotation);
});
}, [documentId, isRotateAvailable, triggerImmediateRotationUpdate]);
useEffect(() => {
const currentRotate = rotateRef.current;
if (currentRotate) {
@@ -52,7 +65,7 @@ function RotateAPIBridgeInner({ documentId }: { documentId: string }) {
return () => {
registerBridge("rotation", null);
};
}, [rotation, registerBridge]);
}, [rotation, isRotateAvailable, registerBridge]);
return null;
}
@@ -0,0 +1,950 @@
import React from "react";
import { useTranslation } from "react-i18next";
import type { MeasureScale, Measurement } from "@app/utils/measurementTypes";
import {
POINT_TO_UNIT,
convertUnit,
generateScaleLabel,
isImperialUnit,
} from "@app/utils/measurementUtils";
export interface RulerPoint {
x: number;
y: number;
}
export interface RulerRenderedMeasurement {
measurement: Measurement;
startS: RulerPoint;
endS: RulerPoint;
distPts: number;
measureScale: MeasureScale | null;
}
export type RulerLabelVisibilityMode = "hideSmall" | "showAll" | "hideAll";
interface RulerMeasurementLayerProps {
measurements: RulerRenderedMeasurement[];
zoom: number;
selectedId: string | null;
hoveredId: string | null;
labelVisibilityMode: RulerLabelVisibilityMode;
isInteractionPassthroughActive: boolean;
liveLine?: {
startS: RulerPoint;
endS: RulerPoint;
measureScale?: MeasureScale | null;
} | null;
firstPoint?: RulerPoint | null;
cursor?: RulerPoint | null;
pageContentRef?: React.Ref<SVGGElement>;
onSelect: (id: string | null) => void;
onDelete: (id: string) => void;
onHoverChange: (id: string | null) => void;
onClearAll: () => void;
onCycleLabelVisibilityMode: () => void;
}
interface LabelBox {
left: number;
top: number;
right: number;
bottom: number;
}
interface MeasurementLineLabels {
scaled: string;
physical: string;
}
export const RULER_DOT_RADIUS = 5;
const TICK = 10;
const LH = 26; // label height (normal, 1 line)
const LH2 = 44; // label height (hovered, no scale, 2 lines)
const LH3 = 62; // label height (hovered, with scale, 3 lines)
const LP = 10; // label horizontal padding
const DEL_R = 8;
const IDLE_LH = 20;
const IDLE_LP = 6;
const IDLE_LABEL_MIN_WIDTH = 42;
const IDLE_LABEL_MIN_LINE_LENGTH = 88;
const IDLE_LABEL_COLLISION_GAP = 6;
const LABEL_LINE_GAP = 8;
const DELETE_LINE_GAP = 10;
const DELETE_LABEL_GAP = 8;
const LINE_HIT_WIDTH = 18;
const LABEL_MODE_BUTTON_WIDTH = 124;
function dist(a: RulerPoint, b: RulerPoint): number {
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
}
function midpoint(a: RulerPoint, b: RulerPoint): RulerPoint {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function perpUnit(a: RulerPoint, b: RulerPoint): { nx: number; ny: number } {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
return { nx: -dy / len, ny: dx / len };
}
function angleDeg(a: RulerPoint, b: RulerPoint): number {
return Math.atan2(Math.abs(b.y - a.y), Math.abs(b.x - a.x)) * (180 / Math.PI);
}
function formatDist(pts: number): string {
const mm = (pts / 72) * 25.4;
if (mm < 100) return `${mm.toFixed(1)} mm`;
if (mm < 1000) return `${(mm / 10).toFixed(1)} cm`;
return `${(mm / 1000).toFixed(2)} m`;
}
function formatInches(pts: number): string {
const inches = pts / 72;
if (inches < 12) return `${inches.toFixed(2)} in`;
return `${(inches / 12).toFixed(2)} ft`;
}
function getDecimalPlaces(value: number): number {
const absVal = Math.abs(value);
const decimalRanges = [
{ threshold: 1000000, decimals: 0 },
{ threshold: 1000, decimals: 2 },
{ threshold: 1, decimals: 3 },
{ threshold: 0.1, decimals: 3 },
{ threshold: 0.01, decimals: 4 },
{ threshold: 0.001, decimals: 5 },
];
return decimalRanges.find((r) => absVal >= r.threshold)?.decimals ?? 6;
}
function formatScaled(pts: number, scale: MeasureScale): string {
const val = pts * scale.factor;
if (val === 0) return `0 ${scale.unit}`;
const decimals = getDecimalPlaces(val);
return `${val.toFixed(decimals)} ${scale.unit}`;
}
function scaledCross(pts: number, scale: MeasureScale): string | null {
const unit = scale.unit.toLowerCase().trim();
if (!Object.hasOwn(POINT_TO_UNIT, unit)) return null;
const valueInUnit = pts * scale.factor;
if (isImperialUnit(scale.unit)) {
const meters = convertUnit(valueInUnit, scale.unit, "m");
if (meters === null) return null;
const decimals = getDecimalPlaces(meters);
return `${meters.toFixed(decimals)} m`;
} else {
const feet = convertUnit(valueInUnit, scale.unit, "ft");
if (feet === null) return null;
const decimals = getDecimalPlaces(feet);
return `${feet.toFixed(decimals)} ft`;
}
}
function getZoomScale(zoom: number): number {
return Math.max(0.6, Math.min(1.0, zoom / 1.5));
}
function getMeasurementLabel(
distPts: number,
measureScale?: MeasureScale | null,
): string {
return measureScale
? formatScaled(distPts, measureScale)
: formatDist(distPts);
}
function preferredPerpUnit(
a: RulerPoint,
b: RulerPoint,
): { nx: number; ny: number } {
const normal = perpUnit(a, b);
if (normal.ny > 0 || (Math.abs(normal.ny) < 0.001 && normal.nx < 0)) {
return { nx: -normal.nx, ny: -normal.ny };
}
return normal;
}
function getLabelCenter(
a: RulerPoint,
b: RulerPoint,
width: number,
height: number,
): RulerPoint {
const mid = midpoint(a, b);
const { nx, ny } = preferredPerpUnit(a, b);
const projectedHalfSize =
(Math.abs(nx) * width) / 2 + (Math.abs(ny) * height) / 2;
const offset = projectedHalfSize + RULER_DOT_RADIUS + LABEL_LINE_GAP;
return {
x: mid.x + nx * offset,
y: mid.y + ny * offset,
};
}
function getIdleLabelDimensions(label: string, zoom: number) {
return {
width: Math.max(label.length * 7 + IDLE_LP * 2, IDLE_LABEL_MIN_WIDTH),
height: Math.max(18, Math.round(IDLE_LH * getZoomScale(zoom))),
};
}
function getIdleLabelBox(
startS: RulerPoint,
endS: RulerPoint,
label: string,
zoom: number,
): LabelBox {
const { width, height } = getIdleLabelDimensions(label, zoom);
const center = getLabelCenter(startS, endS, width, height);
return {
left: center.x - width / 2,
top: center.y - height / 2,
right: center.x + width / 2,
bottom: center.y + height / 2,
};
}
function getBoxFromCenter(
center: RulerPoint,
width: number,
height: number,
): LabelBox {
return {
left: center.x - width / 2,
top: center.y - height / 2,
right: center.x + width / 2,
bottom: center.y + height / 2,
};
}
function boxesOverlap(a: LabelBox, b: LabelBox, gap: number): boolean {
return !(
a.right + gap < b.left ||
a.left - gap > b.right ||
a.bottom + gap < b.top ||
a.top - gap > b.bottom
);
}
function circleOverlapsBox(
center: RulerPoint,
radius: number,
box: LabelBox,
gap: number,
): boolean {
return (
center.x + radius + gap >= box.left &&
center.x - radius - gap <= box.right &&
center.y + radius + gap >= box.top &&
center.y - radius - gap <= box.bottom
);
}
function lineUnit(a: RulerPoint, b: RulerPoint): { ux: number; uy: number } {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
return { ux: dx / len, uy: dy / len };
}
function getDeleteCenter(
startS: RulerPoint,
endS: RulerPoint,
labelCenter: RulerPoint,
labelWidth: number,
labelHeight: number,
): RulerPoint {
const { nx, ny } = preferredPerpUnit(startS, endS);
const endpointCenter = {
x: endS.x + nx * (RULER_DOT_RADIUS + DEL_R + DELETE_LINE_GAP),
y: endS.y + ny * (RULER_DOT_RADIUS + DEL_R + DELETE_LINE_GAP),
};
const labelBox = getBoxFromCenter(labelCenter, labelWidth, labelHeight);
if (!circleOverlapsBox(endpointCenter, DEL_R, labelBox, DELETE_LABEL_GAP)) {
return endpointCenter;
}
const { ux, uy } = lineUnit(startS, endS);
const projectedLabelRadius =
(Math.abs(ux) * labelWidth) / 2 + (Math.abs(uy) * labelHeight) / 2;
return {
x: labelCenter.x + ux * (projectedLabelRadius + DEL_R + DELETE_LABEL_GAP),
y: labelCenter.y + uy * (projectedLabelRadius + DEL_R + DELETE_LABEL_GAP),
};
}
function getVisibleIdleLabelIds(
renderedMeasurements: RulerRenderedMeasurement[],
zoom: number,
): Set<string> {
const visibleIds = new Set<string>();
const occupiedBoxes: LabelBox[] = [];
renderedMeasurements
.map((item, index) => ({
item,
index,
lineLength: dist(item.startS, item.endS),
label: getMeasurementLabel(item.distPts, item.measureScale),
}))
.sort((a, b) => b.lineLength - a.lineLength || a.index - b.index)
.forEach(({ item, lineLength, label }) => {
const box = getIdleLabelBox(item.startS, item.endS, label, zoom);
const labelWidth = box.right - box.left;
const hasEnoughRoom =
lineLength >= Math.max(IDLE_LABEL_MIN_LINE_LENGTH, labelWidth + 24);
const collides = occupiedBoxes.some((occupiedBox) =>
boxesOverlap(box, occupiedBox, IDLE_LABEL_COLLISION_GAP),
);
if (hasEnoughRoom && !collides) {
visibleIds.add(item.measurement.id);
occupiedBoxes.push(box);
}
});
return visibleIds;
}
interface MeasurementLineProps {
id: string;
startS: RulerPoint;
endS: RulerPoint;
distPts: number;
isSelected: boolean;
isHovered: boolean;
onSelect: (id: string | null) => void;
onDelete: (id: string) => void;
onHoverChange: (id: string | null) => void;
measureScale?: MeasureScale | null;
zoom: number;
showIdleLabel: boolean;
expandLabelOnHover: boolean;
isInteractionPassthroughActive: boolean;
labels: MeasurementLineLabels;
}
function MeasurementLine({
id,
startS,
endS,
distPts,
isSelected,
isHovered,
onSelect,
onDelete,
onHoverChange,
measureScale,
zoom,
showIdleLabel,
expandLabelOnHover,
isInteractionPassthroughActive,
labels,
}: MeasurementLineProps) {
const { nx, ny } = preferredPerpUnit(startS, endS);
const ang = angleDeg(startS, endS);
const angLabel = `${ang.toFixed(1)}°`;
const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit);
const distLabel = getMeasurementLabel(distPts, measureScale);
const hoverLine1 = measureScale
? (() => {
const primary = formatScaled(distPts, measureScale);
const cross = scaledCross(distPts, measureScale);
return cross
? `${labels.scaled}: ${primary} / ${cross}`
: `${labels.scaled}: ${primary}`;
})()
: `${formatDist(distPts)} / ${formatInches(distPts)}`;
const hoverLine2 = measureScale
? imperialFirst
? `${labels.physical}: ${formatInches(distPts)} / ${formatDist(distPts)}`
: `${labels.physical}: ${formatDist(distPts)} / ${formatInches(distPts)}`
: null;
const scaleLabel = measureScale
? generateScaleLabel(measureScale.ratio, measureScale.unit)
: null;
const contextLabel = scaleLabel ? `${scaleLabel} ${angLabel}` : angLabel;
const zoomScale = getZoomScale(zoom);
const scaledLH = Math.round(LH * zoomScale);
const scaledLH2 = Math.round(LH2 * zoomScale);
const scaledLH3 = Math.round(LH3 * zoomScale);
const idleDimensions = getIdleLabelDimensions(distLabel, zoom);
const maxHoverLh = measureScale ? scaledLH3 : scaledLH2;
const isHoveredLabelExpanded = isHovered && expandLabelOnHover;
const isInspecting = isSelected || isHoveredLabelExpanded;
const isCompactIdle = !isInspecting;
const showLabel = isSelected || isHovered || showIdleLabel;
const showDelete = isSelected || isHovered;
const lh = isSelected
? maxHoverLh
: isHoveredLabelExpanded
? scaledLH
: idleDimensions.height;
const lwNormal = Math.max(distLabel.length * 8 + LP * 2, 80);
const lwHover = Math.max(
hoverLine1.length * 8 + LP * 2,
(hoverLine2?.length ?? 0) * 8 + LP * 2,
contextLabel.length * 8 + LP * 2,
80,
);
const lw = isSelected
? lwHover
: isHoveredLabelExpanded
? lwNormal
: idleDimensions.width;
const sw = isSelected ? 3 : 2;
const labelCenter = getLabelCenter(startS, endS, lw, lh);
const deleteCenter = getDeleteCenter(startS, endS, labelCenter, lw, lh);
const mono = "'Roboto Mono','Consolas',monospace";
return (
<g
data-ruler-interactive="true"
onPointerEnter={() => {
if (!isInteractionPassthroughActive) {
onHoverChange(id);
}
}}
onPointerLeave={() => onHoverChange(null)}
onClick={(e) => {
if (isInteractionPassthroughActive) {
return;
}
e.stopPropagation();
onSelect(isSelected ? null : id);
}}
style={{
pointerEvents: isInteractionPassthroughActive ? "none" : "all",
cursor: isInteractionPassthroughActive ? "crosshair" : "pointer",
}}
>
<line
x1={startS.x}
y1={startS.y}
x2={endS.x}
y2={endS.y}
stroke="transparent"
strokeWidth={LINE_HIT_WIDTH}
strokeLinecap="round"
pointerEvents="stroke"
/>
<line
x1={startS.x}
y1={startS.y}
x2={endS.x}
y2={endS.y}
stroke="#1e88e5"
strokeWidth={sw}
strokeLinecap="round"
/>
<line
x1={startS.x + (nx * TICK) / 2}
y1={startS.y + (ny * TICK) / 2}
x2={startS.x - (nx * TICK) / 2}
y2={startS.y - (ny * TICK) / 2}
stroke="#1e88e5"
strokeWidth={sw}
strokeLinecap="round"
/>
<line
x1={endS.x + (nx * TICK) / 2}
y1={endS.y + (ny * TICK) / 2}
x2={endS.x - (nx * TICK) / 2}
y2={endS.y - (ny * TICK) / 2}
stroke="#1e88e5"
strokeWidth={sw}
strokeLinecap="round"
/>
<circle
cx={startS.x}
cy={startS.y}
r={RULER_DOT_RADIUS}
fill="#1e88e5"
stroke="white"
strokeWidth={2}
/>
<circle
cx={endS.x}
cy={endS.y}
r={RULER_DOT_RADIUS}
fill="#1e88e5"
stroke="white"
strokeWidth={2}
/>
{showLabel && (
<g style={{ pointerEvents: "all", cursor: "pointer" }}>
<rect
x={labelCenter.x - lw / 2 - 4}
y={labelCenter.y - lh / 2 - 4}
width={lw + 8}
height={lh + 8}
fill="transparent"
stroke="none"
/>
<rect
x={labelCenter.x - lw / 2}
y={labelCenter.y - lh / 2}
width={lw}
height={lh}
rx={isCompactIdle ? 4 : 5}
fill={isCompactIdle ? "rgba(255,255,255,0.94)" : "white"}
stroke="#1e88e5"
strokeWidth={isCompactIdle ? 1 : 1.5}
filter="url(#ruler-shadow)"
/>
{isSelected && measureScale ? (
<>
<text
x={labelCenter.x}
y={labelCenter.y - Math.round(17 * zoomScale)}
textAnchor="middle"
dominantBaseline="middle"
fill="#1e88e5"
fontSize={12}
fontFamily={mono}
fontWeight={600}
style={{ userSelect: "none" }}
>
{hoverLine1}
</text>
<text
x={labelCenter.x}
y={labelCenter.y}
textAnchor="middle"
dominantBaseline="middle"
fill="#546e7a"
fontSize={11}
fontFamily={mono}
fontWeight={500}
style={{ userSelect: "none" }}
>
{hoverLine2}
</text>
<text
x={labelCenter.x}
y={labelCenter.y + Math.round(17 * zoomScale)}
textAnchor="middle"
dominantBaseline="middle"
fill="#5c6bc0"
fontSize={10}
fontFamily={mono}
fontWeight={500}
style={{ userSelect: "none" }}
>
{contextLabel}
</text>
</>
) : isSelected ? (
<>
<text
x={labelCenter.x}
y={labelCenter.y - Math.round(6 * zoomScale)}
textAnchor="middle"
dominantBaseline="middle"
fill="#1e88e5"
fontSize={12}
fontFamily={mono}
fontWeight={600}
style={{ userSelect: "none" }}
>
{hoverLine1}
</text>
<text
x={labelCenter.x}
y={labelCenter.y + Math.round(13 * zoomScale)}
textAnchor="middle"
dominantBaseline="middle"
fill="#5c6bc0"
fontSize={11}
fontFamily={mono}
fontWeight={500}
style={{ userSelect: "none" }}
>
{contextLabel}
</text>
</>
) : (
<text
x={labelCenter.x}
y={labelCenter.y + 1}
textAnchor="middle"
dominantBaseline="middle"
fill="#1e88e5"
fontSize={isCompactIdle ? 10 : 12}
fontFamily={mono}
fontWeight={600}
style={{ userSelect: "none" }}
>
{distLabel}
</text>
)}
</g>
)}
{showDelete && (
<>
<line
x1={endS.x}
y1={endS.y}
x2={deleteCenter.x}
y2={deleteCenter.y}
stroke="transparent"
strokeWidth={DEL_R * 2}
strokeLinecap="round"
pointerEvents="stroke"
/>
<g
style={{ cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
onDelete(id);
}}
>
<circle
cx={deleteCenter.x}
cy={deleteCenter.y}
r={DEL_R}
fill="#ef5350"
stroke="white"
strokeWidth={1.5}
/>
<text
x={deleteCenter.x}
y={deleteCenter.y}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={12}
fontWeight={700}
style={{ userSelect: "none" }}
>
×
</text>
</g>
</>
)}
</g>
);
}
interface LiveLineProps {
startS: RulerPoint;
endS: RulerPoint;
zoom: number;
measureScale?: MeasureScale | null;
}
function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) {
const d = dist(startS, endS) / zoom;
const { nx, ny } = preferredPerpUnit(startS, endS);
const ang = angleDeg(startS, endS);
const distLabel = measureScale
? formatScaled(d, measureScale)
: formatDist(d);
const lw = Math.max(distLabel.length * 8 + LP * 2, 80);
const lh = Math.round(LH2 * getZoomScale(zoom));
const labelCenter = getLabelCenter(startS, endS, lw, lh);
return (
<g>
<line
x1={startS.x}
y1={startS.y}
x2={endS.x}
y2={endS.y}
stroke="#1e88e5"
strokeWidth={2}
strokeDasharray="7 4"
strokeLinecap="round"
opacity={0.85}
/>
<line
x1={startS.x + (nx * TICK) / 2}
y1={startS.y + (ny * TICK) / 2}
x2={startS.x - (nx * TICK) / 2}
y2={startS.y - (ny * TICK) / 2}
stroke="#1e88e5"
strokeWidth={2}
strokeLinecap="round"
/>
{d > 4 && (
<g>
<rect
x={labelCenter.x - lw / 2}
y={labelCenter.y - lh / 2}
width={lw}
height={lh}
rx={5}
fill="#1e88e5"
stroke="white"
strokeWidth={1}
/>
<text
x={labelCenter.x}
y={labelCenter.y - 6}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={12}
fontFamily="'Roboto Mono','Consolas',monospace"
fontWeight={600}
style={{ userSelect: "none" }}
>
{distLabel}
</text>
<text
x={labelCenter.x}
y={labelCenter.y + 13}
textAnchor="middle"
dominantBaseline="middle"
fill="rgba(255,255,255,0.85)"
fontSize={11}
fontFamily="'Roboto Mono','Consolas',monospace"
fontWeight={500}
style={{ userSelect: "none" }}
>
{`${ang.toFixed(1)}°`}
</text>
</g>
)}
</g>
);
}
export function RulerMeasurementLayer({
measurements,
zoom,
selectedId,
hoveredId,
labelVisibilityMode,
isInteractionPassthroughActive,
liveLine,
firstPoint,
cursor,
pageContentRef,
onSelect,
onDelete,
onHoverChange,
onClearAll,
onCycleLabelVisibilityMode,
}: RulerMeasurementLayerProps) {
const { t } = useTranslation();
const measurementLineLabels = React.useMemo(
() => ({
scaled: t("ruler.scaled", "Scaled"),
physical: t("ruler.physicalValues", "Physical"),
}),
[t],
);
const visibleIdleLabelIds = React.useMemo(
() => getVisibleIdleLabelIds(measurements, zoom),
[measurements, zoom],
);
const orderedMeasurements = React.useMemo(() => {
const getRank = (item: RulerRenderedMeasurement) => {
if (item.measurement.id === hoveredId) {
return 2;
}
if (item.measurement.id === selectedId) {
return 1;
}
return 0;
};
return [...measurements].sort((a, b) => getRank(a) - getRank(b));
}, [hoveredId, measurements, selectedId]);
const getShouldShowIdleLabel = (measurementId: string) => {
if (labelVisibilityMode === "showAll") {
return true;
}
if (labelVisibilityMode === "hideAll") {
return false;
}
return visibleIdleLabelIds.has(measurementId);
};
const labelModeButtonLabel =
labelVisibilityMode === "hideSmall"
? t("ruler.showAllLabels", "Show all labels")
: labelVisibilityMode === "showAll"
? t("ruler.hideAllLabels", "Hide all labels")
: t("ruler.hideSmallLabels", "Hide small labels");
return (
<>
<g ref={pageContentRef}>
{orderedMeasurements.map((item) => {
const { measurement, startS, endS, distPts, measureScale } = item;
return (
<MeasurementLine
key={measurement.id}
id={measurement.id}
startS={startS}
endS={endS}
distPts={distPts}
isSelected={
!isInteractionPassthroughActive && selectedId === measurement.id
}
isHovered={
!isInteractionPassthroughActive && hoveredId === measurement.id
}
onSelect={onSelect}
onDelete={onDelete}
onHoverChange={onHoverChange}
measureScale={measureScale}
zoom={zoom}
showIdleLabel={getShouldShowIdleLabel(measurement.id)}
expandLabelOnHover={labelVisibilityMode !== "showAll"}
isInteractionPassthroughActive={isInteractionPassthroughActive}
labels={measurementLineLabels}
/>
);
})}
{firstPoint && (
<circle
cx={firstPoint.x}
cy={firstPoint.y}
r={RULER_DOT_RADIUS}
fill="#1e88e5"
stroke="white"
strokeWidth={2}
/>
)}
</g>
{liveLine && (
<LiveLine
startS={liveLine.startS}
endS={liveLine.endS}
zoom={zoom}
measureScale={liveLine.measureScale}
/>
)}
{cursor && (
<g opacity={0.75}>
<line
x1={cursor.x - 12}
y1={cursor.y}
x2={cursor.x + 12}
y2={cursor.y}
stroke="#1e88e5"
strokeWidth={1.5}
/>
<line
x1={cursor.x}
y1={cursor.y - 12}
x2={cursor.x}
y2={cursor.y + 12}
stroke="#1e88e5"
strokeWidth={1.5}
/>
<circle cx={cursor.x} cy={cursor.y} r={2} fill="#1e88e5" />
</g>
)}
{measurements.length > 0 && (
<>
<g
data-ruler-interactive="true"
data-ruler-control="true"
style={{ pointerEvents: "all", cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
onClearAll();
}}
>
<rect
x={8}
y={8}
width={88}
height={26}
rx={5}
fill="rgba(239,83,80,0.9)"
stroke="white"
strokeWidth={1}
/>
<text
x={52}
y={25}
textAnchor="middle"
fill="white"
fontSize={12}
fontFamily="sans-serif"
fontWeight={600}
style={{ userSelect: "none" }}
>
{t("ruler.clearAll", "Clear all")}
</text>
</g>
<g
data-ruler-interactive="true"
data-ruler-control="true"
style={{ pointerEvents: "all", cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
onCycleLabelVisibilityMode();
}}
>
<rect
x={8}
y={40}
width={LABEL_MODE_BUTTON_WIDTH}
height={26}
rx={5}
fill="rgba(30,136,229,0.9)"
stroke="white"
strokeWidth={1}
/>
<text
x={8 + LABEL_MODE_BUTTON_WIDTH / 2}
y={57}
textAnchor="middle"
fill="white"
fontSize={12}
fontFamily="sans-serif"
fontWeight={600}
style={{ userSelect: "none" }}
>
{labelModeButtonLabel}
</text>
</g>
</>
)}
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
import { useRef } from "react";
import { Popover } from "@mantine/core";
import SettingsIcon from "@mui/icons-material/Settings";
import { Tooltip, type TooltipProps } from "@app/components/shared/Tooltip";
import { ScaleSettingsPanel } from "@app/components/viewer/ScaleSettingsPanel";
import type { MeasureScale } from "@app/utils/measurementTypes";
import { ActionIcon } from "@app/ui/ActionIcon";
interface RulerScaleSettingsButtonProps {
disabled?: boolean;
label: string;
tooltipPosition: NonNullable<TooltipProps["position"]>;
currentScale?: MeasureScale | null;
onApplyScale?: (scale: MeasureScale) => void;
onResetScale?: () => void;
onStartCalibration?: () => void;
onCancelCalibration?: () => void;
isCalibrationActive?: boolean;
}
export function RulerScaleSettingsButton({
disabled,
label,
tooltipPosition,
currentScale,
onApplyScale,
onResetScale,
onStartCalibration,
onCancelCalibration,
isCalibrationActive,
}: RulerScaleSettingsButtonProps) {
const scalePopoverRef = useRef<HTMLButtonElement>(null);
return (
<Popover
position={tooltipPosition}
withArrow
shadow="md"
offset={8}
withinPortal
>
<Popover.Target>
<div style={{ display: "inline-flex" }}>
<Tooltip
content={label}
position={tooltipPosition}
offset={12}
arrow
portalTarget={document.body}
>
<ActionIcon
ref={scalePopoverRef}
variant="primary"
className="workbench-bar-action-icon"
disabled={disabled}
aria-label={label}
>
<SettingsIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</div>
</Popover.Target>
<Popover.Dropdown>
<ScaleSettingsPanel
currentScale={currentScale}
onApplyScale={(scale) => {
onApplyScale?.(scale);
}}
onResetScale={() => {
onResetScale?.();
}}
onStartCalibration={onStartCalibration}
onCancelCalibration={onCancelCalibration}
isCalibrationActive={isCalibrationActive}
onClose={() => {
scalePopoverRef.current?.click();
}}
/>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,190 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import type { MeasureScale, PagePoint } from "@app/utils/measurementTypes";
import {
UNIT_OPTIONS,
formatPaperDistance,
validateRealDistance,
calculateCalibratedScale,
generateScaleLabel,
} from "@app/utils/measurementUtils";
import {
getLastCalibrationUnit,
setLastCalibrationUnit,
} from "@app/utils/measurementPreferences";
// Result of user drawing measurement on PDF (from RulerOverlay)
export interface ScaleCalibrationMeasurement {
start: PagePoint;
end: PagePoint;
pdfDistancePts: number;
}
interface ScaleCalibrationDialogProps {
opened: boolean;
measurement: ScaleCalibrationMeasurement | null;
defaultUnit: string;
onApplyScale: (scale: MeasureScale) => void;
onClose: () => void;
}
export function ScaleCalibrationDialog({
opened,
measurement,
defaultUnit,
onApplyScale,
onClose,
}: ScaleCalibrationDialogProps) {
const { t } = useTranslation();
const [realDistance, setRealDistance] = useState<number | null>(null);
const [unit, setUnit] = useState(() => getLastCalibrationUnit(defaultUnit));
const [error, setError] = useState<string | null>(null);
const wasOpenedRef = useRef(opened);
const previewScale = useMemo(() => {
if (measurement == null || realDistance == null) {
return null;
}
try {
return calculateCalibratedScale(
measurement.pdfDistancePts,
realDistance,
unit,
);
} catch {
return null;
}
}, [measurement, realDistance, unit]);
useEffect(() => {
const isOpening = opened && !wasOpenedRef.current;
wasOpenedRef.current = opened;
if (isOpening) {
setRealDistance(null);
setUnit(getLastCalibrationUnit(defaultUnit));
setError(null);
}
}, [opened, defaultUnit]);
const handleRealDistanceChange = (value: number | string | null) => {
setError(null);
const validated = validateRealDistance(value);
setRealDistance(validated);
};
const handleUnitChange = (newUnit: string | null) => {
if (newUnit == null) return;
setUnit(newUnit);
setLastCalibrationUnit(newUnit);
setError(null);
};
const handleApply = () => {
if (measurement == null || realDistance == null) {
setError(
t(
"scaleSettings.calibrationDistanceRequired",
"Enter a real-world distance greater than zero",
),
);
return;
}
try {
const scale = calculateCalibratedScale(
measurement.pdfDistancePts,
realDistance,
unit,
);
setLastCalibrationUnit(unit);
onApplyScale(scale);
} catch (err) {
console.error("[Calibration] Failed to apply:", err);
setError(
t(
"scaleSettings.calibrationInvalid",
"Unable to calculate scale from this measurement",
),
);
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("scaleSettings.calibrationTitle", "Calibrate Scale")}
centered
size="sm"
>
<Stack gap="md">
{/* Show paper distance automatically */}
{measurement && (
<Text size="sm" c="dimmed">
{t(
"scaleSettings.calibrationPaperDistance",
"Measured page distance: {{distance}}",
{
distance: formatPaperDistance(measurement.pdfDistancePts),
},
)}
</Text>
)}
{/* Input: real distance + unit selector */}
<Group grow align="flex-start">
<NumberInput
label={t("scaleSettings.realDistance", "Real-world distance")}
placeholder={t(
"scaleSettings.realDistancePlaceholder",
"e.g., 5 (actual distance in {{unit}})",
{ unit },
)}
value={realDistance ?? undefined}
onChange={handleRealDistanceChange}
min={0}
step={0.1}
decimalScale={4}
error={error}
/>
<Select
label={t("scaleSettings.unit", "Unit")}
data={UNIT_OPTIONS}
value={unit}
onChange={handleUnitChange}
searchable
/>
</Group>
{/* Preview: show calculated scale */}
{previewScale && (
<Text size="sm" c="blue">
{t(
"scaleSettings.calculatedScale",
"Calculated scale (1 page unit = X real units)",
)}
: {generateScaleLabel(previewScale.ratio, previewScale.unit)}
</Text>
)}
{/* Actions: Cancel or Apply */}
<Group justify="flex-end">
<Button variant="secondary" onClick={onClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button onClick={handleApply}>
{t("scaleSettings.applyCalibration", "Apply Calibration")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,321 @@
import React, { useCallback, useEffect, useState } from "react";
import { Group, NumberInput, Select, Stack, Text, Grid } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import type { MeasureScale } from "@app/utils/measurementTypes";
import {
generateScaleLabel,
parsePresetRatio,
calculateScaleFactor,
UNIT_OPTIONS,
} from "@app/utils/measurementUtils";
// ─── Constants ─────────────────────────────────────────────────────────────
// Preset scales as architectural ratios (e.g., "1:100" = 1 page unit = 100 real-world units)
const PRESET_SCALES = ["1:5", "1:10", "1:20", "1:50", "1:100", "1:150"];
// ─── Component ────────────────────────────────────────────────────────────
interface ScaleSettingsPanelProps {
onApplyScale: (scale: MeasureScale) => void;
onResetScale?: () => void;
onStartCalibration?: () => void;
onCancelCalibration?: () => void;
isCalibrationActive?: boolean;
currentScale?: MeasureScale | null;
onClose?: () => void;
}
export function ScaleSettingsPanel({
onApplyScale,
onResetScale,
onStartCalibration,
onCancelCalibration,
isCalibrationActive = false,
currentScale,
onClose,
}: ScaleSettingsPanelProps) {
const { t } = useTranslation();
const [ratio, setRatio] = useState<string | number>(
currentScale?.ratio ?? "",
);
const [unit, setUnit] = useState<string>(currentScale?.unit || "m");
const [presetSelected, setPresetSelected] = useState<string | null>(null);
const [ratioError, setRatioError] = useState<string | null>(null);
const [unitError, setUnitError] = useState<string | null>(null);
// Helper functions to clear errors
const clearErrors = useCallback(() => {
setRatioError(null);
setUnitError(null);
}, []);
const clearRatioError = useCallback(() => {
setRatioError(null);
}, []);
const clearUnitError = useCallback(() => {
setUnitError(null);
}, []);
// Sync form fields when currentScale changes
useEffect(() => {
if (currentScale) {
// ratio can be null, so only set it if it exists
setRatio(currentScale.ratio ?? "");
setUnit(currentScale.unit);
clearErrors();
// Check if current scale matches any preset
const matchedPreset =
currentScale.ratio !== null
? PRESET_SCALES.find(
(preset) => parsePresetRatio(preset) === currentScale.ratio,
)
: null;
setPresetSelected(matchedPreset ?? null);
} else {
// Reset to defaults when no active scale
setRatio("");
setUnit("m");
setPresetSelected(null);
clearErrors();
}
}, [currentScale, clearErrors]);
const handlePresetClick = (preset: string) => {
const presetRatio = parsePresetRatio(preset);
if (presetRatio === null) return;
setPresetSelected(preset);
setRatio(presetRatio);
clearRatioError();
// Live update: apply immediately for presets
applyScale(presetRatio, unit);
// Close panel after applying preset
onClose?.();
};
const handleRatioChange = (value: string | number) => {
clearRatioError();
if (value === "") {
setRatio("");
setPresetSelected(null);
return;
}
setRatio(value);
// Clear preset selection when user enters custom value
setPresetSelected(null);
};
const parseRatioValue = (value: string | number): number | null => {
if (typeof value === "number") return Number.isNaN(value) ? null : value;
const trimmedValue = value.trim();
if (!trimmedValue) return null;
const numValue = Number(trimmedValue);
return Number.isNaN(numValue) ? null : numValue;
};
const handleUnitChange = (val: string | null) => {
if (!val) return;
setUnit(val);
clearUnitError();
// Auto-apply immediately if ratio is set (better UX consistency)
const numValue = parseRatioValue(ratio);
if (numValue && numValue > 0) {
// Close panel after auto-applying unit change
applyScale(numValue, val, true);
}
};
const validateRatio = (): number | null => {
const numValue = parseRatioValue(ratio);
if (numValue === null || numValue === undefined || isNaN(numValue)) {
setRatioError(t("scaleSettings.ratioRequired", "Ratio is required"));
return null;
}
if (numValue <= 0) {
setRatioError(
t("scaleSettings.ratioPositive", "Ratio must be greater than zero"),
);
return null;
}
return numValue;
};
const applyScale = (
scaleRatio: number,
scaleUnit: string,
shouldClose = false,
) => {
if (!scaleRatio || scaleRatio <= 0) return;
try {
// Calculate the raw factor from the ratio
const factor = calculateScaleFactor(scaleRatio, scaleUnit);
const scale: MeasureScale = {
factor,
ratio: scaleRatio,
unit: scaleUnit,
};
onApplyScale(scale);
clearUnitError();
// Close panel only if explicitly requested
if (shouldClose) {
onClose?.();
}
} catch (err) {
console.error("Invalid unit:", scaleUnit, err);
setUnitError(
t("scaleSettings.unitInvalid", "Invalid unit: {{scaleUnit}}", {
scaleUnit,
}),
);
}
};
const handleApply = () => {
const numValue = validateRatio();
if (numValue === null) return;
// Close panel after manual apply (if validation passed)
applyScale(numValue, unit, true);
};
const handleCalibrationToggle = () => {
clearErrors();
if (isCalibrationActive) {
onCancelCalibration?.();
} else {
onStartCalibration?.();
}
onClose?.();
};
return (
<Stack gap="md" style={{ padding: "0.75rem", minWidth: "300px" }}>
{/* Preset Scales Section */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("scaleSettings.presets", "Preset Scales")}
</Text>
<Grid gutter="xs">
{PRESET_SCALES.map((preset) => (
<Grid.Col span={4} key={preset}>
<Button
variant={presetSelected === preset ? "primary" : "tertiary"}
size="sm"
fontSize="xs"
fullWidth
onClick={() => handlePresetClick(preset)}
>
{preset}
</Button>
</Grid.Col>
))}
</Grid>
</div>
{/* Custom Scale Section */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("scaleSettings.customScale", "Custom Scale")}
</Text>
<Group grow>
<div>
<NumberInput
key={`ratio-${currentScale?.ratio ?? "empty"}`}
label={t("scaleSettings.ratio", "Scale Ratio")}
placeholder={t(
"scaleSettings.ratioPlaceholder",
"e.g., 100 (1 page unit = 100 real units)",
)}
value={ratio}
onChange={handleRatioChange}
min={0.1}
step={1}
size="xs"
error={ratioError}
/>
</div>
<div onMouseDown={(e) => e.stopPropagation()}>
<Select
label={t("scaleSettings.unit", "Unit")}
data={UNIT_OPTIONS}
value={unit}
onChange={handleUnitChange}
size="xs"
searchable
error={unitError}
/>
</div>
</Group>
<Text size="xs" c="dimmed" mt="xs">
{t(
"scaleSettings.ratioHelp",
"Ratio: 1 page unit = X real-world units",
)}
</Text>
</div>
<Button onClick={handleApply} fullWidth size="sm">
{t("scaleSettings.apply", "Apply Scale")}
</Button>
{/* Active Scale Display */}
<div
style={{
padding: "0.5rem",
backgroundColor: currentScale
? "rgba(30, 136, 229, 0.1)"
: "rgba(158, 158, 158, 0.1)",
borderRadius: "4px",
}}
>
<Text size="xs" c={currentScale ? "blue" : "dimmed"}>
<strong>{t("scaleSettings.activeScale", "Active Scale")}:</strong>{" "}
{currentScale && currentScale.ratio
? generateScaleLabel(currentScale.ratio, currentScale.unit)
: currentScale && !currentScale.ratio
? `${currentScale.unit} (custom)`
: t("scaleSettings.noneSet", "No custom scale set")}
</Text>
</div>
{/* Calibration Mode */}
<Button
onClick={handleCalibrationToggle}
fullWidth
variant={isCalibrationActive ? "primary" : "secondary"}
size="sm"
disabled={
isCalibrationActive ? !onCancelCalibration : !onStartCalibration
}
title={t(
"scaleSettings.calibrationTooltip",
"Measure a known distance to calculate the scale automatically",
)}
>
{isCalibrationActive
? t("scaleSettings.calibrating", "Calibrating")
: t("scaleSettings.calibrate", "Calibrate")}
</Button>
{/* Reset Button */}
{currentScale && (
<Button
onClick={() => onResetScale?.()}
fullWidth
variant="secondary"
size="sm"
>
{t("scaleSettings.reset", "Reset to Defaults")}
</Button>
)}
</Stack>
);
}
@@ -26,11 +26,19 @@ import StraightenIcon from "@mui/icons-material/Straighten";
import LayersIcon from "@mui/icons-material/Layers";
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
import StopIcon from "@mui/icons-material/Stop";
import SettingsIcon from "@mui/icons-material/Settings";
import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud";
import { RulerScaleSettingsButton } from "@app/components/viewer/RulerScaleSettingsButton";
import type { MeasureScale } from "@app/utils/measurementTypes";
export function useViewerWorkbenchBarButtons(
isRulerActive?: boolean,
setIsRulerActive?: (v: boolean) => void,
customScale?: MeasureScale | null,
setCustomScale?: (scale: MeasureScale | null) => void,
isScaleCalibrationActive?: boolean,
startScaleCalibration?: () => void,
cancelScaleCalibration?: () => void,
) {
const { t, i18n } = useTranslation();
const viewer = useViewer();
@@ -118,11 +126,36 @@ export function useViewerWorkbenchBarButtons(
const annotationsLabel = t("workbenchBar.annotations", "Annotations");
const formFillLabel = t("workbenchBar.formFill", "Fill Form");
const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure");
const rulerSettingsLabel = t("workbenchBar.rulerSettings", "Scale Settings");
const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud");
const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed");
const isFormFillActive = (selectedTool as string) === "formFill";
const handleStartScaleCalibration = useCallback(() => {
startScaleCalibration?.();
setIsRulerActive?.(true);
if (isPanning) {
viewer.panActions.disablePan();
setIsPanning(false);
}
}, [isPanning, setIsRulerActive, startScaleCalibration, viewer.panActions]);
const handleCancelScaleCalibration = useCallback(() => {
cancelScaleCalibration?.();
}, [cancelScaleCalibration]);
const handleApplyRulerScale = useCallback(
(scale: MeasureScale) => {
setCustomScale?.(scale);
},
[setCustomScale],
);
const handleResetRulerScale = useCallback(() => {
setCustomScale?.(null);
}, [setCustomScale]);
// Filter languages based on available voices
const filteredLanguages = useMemo(
() =>
@@ -234,6 +267,32 @@ export function useViewerWorkbenchBarButtons(
}
},
},
// Ruler scale settings button - only visible when ruler is active
...(isRulerActive
? [
{
id: "viewer-ruler-settings",
icon: <SettingsIcon sx={{ fontSize: "1.5rem" }} />,
tooltip: rulerSettingsLabel,
ariaLabel: rulerSettingsLabel,
section: "top" as const,
order: 25.5,
render: ({ disabled }: { disabled?: boolean }) => (
<RulerScaleSettingsButton
disabled={disabled}
label={rulerSettingsLabel}
tooltipPosition={tooltipPosition}
currentScale={customScale}
onApplyScale={handleApplyRulerScale}
onResetScale={handleResetRulerScale}
onStartCalibration={handleStartScaleCalibration}
onCancelCalibration={handleCancelScaleCalibration}
isCalibrationActive={isScaleCalibrationActive}
/>
),
},
]
: []),
{
id: "viewer-rotate-left",
icon: <LocalIcon icon="rotate-left" width="1rem" height="1rem" />,
@@ -553,8 +612,15 @@ export function useViewerWorkbenchBarButtons(
formFillLabel,
isFormFillActive,
rulerLabel,
rulerSettingsLabel,
isRulerActive,
setIsRulerActive,
handleStartScaleCalibration,
handleCancelScaleCalibration,
handleApplyRulerScale,
handleResetRulerScale,
customScale,
isScaleCalibrationActive,
readAloudLabel,
readAloudSpeedLabel,
isReadingAloud,
@@ -6,6 +6,8 @@ import {
} from "@app/contexts/AppConfigContext";
import apiClient from "@app/services/apiClient";
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode } from "react";
// Mock apiClient
@@ -26,7 +28,9 @@ describe("AppConfigContext", () => {
});
const wrapper = ({ children }: { children: ReactNode }) => (
<AppConfigProvider>{children}</AppConfigProvider>
<TestQueryProvider>
<AppConfigProvider>{children}</AppConfigProvider>
</TestQueryProvider>
);
it("should fetch and provide app config on non-auth pages", async () => {
@@ -261,9 +265,11 @@ describe("AppConfigContext", () => {
};
const customWrapper = ({ children }: { children: ReactNode }) => (
<AppConfigProvider initialConfig={initialConfig}>
{children}
</AppConfigProvider>
<TestQueryProvider>
<AppConfigProvider initialConfig={initialConfig}>
{children}
</AppConfigProvider>
</TestQueryProvider>
);
const { result } = renderHook(() => useAppConfig(), {
@@ -279,6 +285,225 @@ describe("AppConfigContext", () => {
expect(apiClient.get).toHaveBeenCalled();
});
it("fetches on an auth page once a JWT arrives, flipping loading", async () => {
// Signing in on /login must load the config the first-login password
// prompt reads. useOnboardingOrchestrator keys its effect on
// [config?.enableLogin, configLoading] — both primitives — so the config
// value changing is not enough. loading has to go true then false, or the
// prompt never opens.
Object.defineProperty(window, "location", {
value: { pathname: "/login" },
writable: true,
});
let resolveFetch: (value: unknown) => void = () => {};
vi.mocked(apiClient.get).mockReturnValue(
new Promise((resolve) => {
resolveFetch = resolve;
}) as never,
);
const { result } = renderHook(() => useAppConfig(), { wrapper });
expect(apiClient.get).not.toHaveBeenCalled();
expect(result.current.loading).toBe(false);
await act(async () => {
window.dispatchEvent(new CustomEvent("jwt-available"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
await waitFor(() => expect(result.current.loading).toBe(true));
await act(async () => {
resolveFetch({ status: 200, data: { enableLogin: true, isAdmin: true } });
await new Promise((resolve) => setTimeout(resolve, 0));
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.config).toEqual({
enableLogin: true,
isAdmin: true,
});
});
it("flips loading after sign-in even when a pre-login 401 already cached a config", async () => {
// The live sequence: the app loads on "/" while logged out, app-config 401s
// and resolves to the login-enabled default, then the user signs in on
// /login. That cached default means isPending is already false, so loading
// has to track isFetching or useOnboardingOrchestrator — whose effect deps
// are [config?.enableLogin, configLoading], both unchanged here — never
// re-runs, and the first-login password prompt never opens.
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const sharedWrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>
<AppConfigProvider>{children}</AppConfigProvider>
</QueryClientProvider>
);
// Logged out on a non-auth page: 401 → default config, cached.
vi.mocked(apiClient.get).mockRejectedValueOnce(
Object.assign(new Error("Unauthorized"), {
response: { status: 401, data: {} },
}),
);
const loggedOut = renderHook(() => useAppConfig(), {
wrapper: sharedWrapper,
});
await waitFor(() =>
expect(loggedOut.result.current.config).toEqual({ enableLogin: true }),
);
expect(loggedOut.result.current.loading).toBe(false);
loggedOut.unmount();
// Now on /login, with that default still cached.
Object.defineProperty(window, "location", {
value: { pathname: "/login" },
writable: true,
});
let resolveFetch: (value: unknown) => void = () => {};
vi.mocked(apiClient.get).mockReturnValue(
new Promise((resolve) => {
resolveFetch = resolve;
}) as never,
);
const { result } = renderHook(() => useAppConfig(), {
wrapper: sharedWrapper,
});
expect(result.current.loading).toBe(false);
await act(async () => {
window.dispatchEvent(new CustomEvent("jwt-available"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
await waitFor(() => expect(result.current.loading).toBe(true));
await act(async () => {
resolveFetch({
status: 200,
data: { enableLogin: true, isAdmin: true },
});
await new Promise((resolve) => setTimeout(resolve, 0));
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.config).toEqual({
enableLogin: true,
isAdmin: true,
});
});
it("serves a remounted provider from cache", async () => {
vi.mocked(apiClient.get).mockResolvedValue({
status: 200,
data: { enableLogin: false },
} as any);
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const sharedWrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>
<AppConfigProvider>{children}</AppConfigProvider>
</QueryClientProvider>
);
const first = renderHook(() => useAppConfig(), { wrapper: sharedWrapper });
await waitFor(() => expect(first.result.current.loading).toBe(false));
first.unmount();
const second = renderHook(() => useAppConfig(), { wrapper: sharedWrapper });
// Cached, so no loading flash and no second request.
expect(second.result.current.loading).toBe(false);
expect(second.result.current.config).toEqual({ enableLogin: false });
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
it("honours maxRetries for network failures", async () => {
expectConsole.error(/\[AppConfig\] Failed to fetch app config/);
vi.mocked(apiClient.get).mockRejectedValue(new Error("boom"));
const retryWrapper = ({ children }: { children: ReactNode }) => (
<TestQueryProvider>
<AppConfigProvider retryOptions={{ maxRetries: 2, initialDelay: 1 }}>
{children}
</AppConfigProvider>
</TestQueryProvider>
);
const { result } = renderHook(() => useAppConfig(), {
wrapper: retryWrapper,
});
await waitFor(() => expect(result.current.error).toBe("boom"));
expect(apiClient.get).toHaveBeenCalledTimes(3);
});
it("does not retry a 4xx", async () => {
expectConsole.error(/\[AppConfig\] Failed to fetch app config/);
vi.mocked(apiClient.get).mockRejectedValue(
Object.assign(new Error("nope"), { response: { status: 403, data: {} } }),
);
const retryWrapper = ({ children }: { children: ReactNode }) => (
<TestQueryProvider>
<AppConfigProvider retryOptions={{ maxRetries: 5, initialDelay: 1 }}>
{children}
</AppConfigProvider>
</TestQueryProvider>
);
const { result } = renderHook(() => useAppConfig(), {
wrapper: retryWrapper,
});
await waitFor(() => expect(result.current.error).toBe("nope"));
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
it("stays loading when autoFetch is off and nothing seeds a config", async () => {
// Unresolved, not in-flight. Matches the pre-migration provider, which
// seeded loading from !hasResolvedConfig and never cleared it without a
// fetch. Reporting false here would tell consumers a null config is final.
const offWrapper = ({ children }: { children: ReactNode }) => (
<TestQueryProvider>
<AppConfigProvider autoFetch={false}>{children}</AppConfigProvider>
</TestQueryProvider>
);
const { result } = renderHook(() => useAppConfig(), {
wrapper: offWrapper,
});
expect(result.current.loading).toBe(true);
expect(result.current.config).toBeNull();
expect(apiClient.get).not.toHaveBeenCalled();
});
it("does not fetch when autoFetch is off", async () => {
const offWrapper = ({ children }: { children: ReactNode }) => (
<TestQueryProvider>
<AppConfigProvider
initialConfig={{ enableLogin: false }}
bootstrapMode="non-blocking"
autoFetch={false}
>
{children}
</AppConfigProvider>
</TestQueryProvider>
);
const { result } = renderHook(() => useAppConfig(), {
wrapper: offWrapper,
});
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual({ enableLogin: false });
expect(apiClient.get).not.toHaveBeenCalled();
});
it("should use suppressErrorToast for all config requests", async () => {
const mockConfig = { enableLogin: true };
@@ -2,23 +2,18 @@ import React, {
createContext,
useContext,
useMemo,
useState,
useEffect,
ReactNode,
useCallback,
useState,
ReactNode,
} from "react";
import apiClient from "@app/services/apiClient";
import { getSimulatedAppConfig } from "@app/testing/serverExperienceSimulations";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_APP_CONFIG, fetchAppConfig } from "@app/api/config";
import { qk } from "@app/query/keys";
import { CONFIG_STALE_TIME } from "@app/query/staleTime";
import type { AppConfig, AppConfigBootstrapMode } from "@app/types/appConfig";
import { useJwtConfigSync } from "@app/hooks/useJwtConfigSync";
/**
* Sleep utility for delays
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export interface AppConfigRetryOptions {
maxRetries?: number;
initialDelay?: number;
@@ -40,10 +35,6 @@ const AppConfigContext = createContext<AppConfigContextValue | undefined>({
refetch: async () => {},
});
/**
* Provider component that fetches and provides app configuration
* Should be placed at the top level of the app, before any components that need config
*/
export interface AppConfigProviderProps {
children: ReactNode;
retryOptions?: AppConfigRetryOptions;
@@ -53,6 +44,22 @@ export interface AppConfigProviderProps {
onConfigLoaded?: (config: AppConfig) => void;
}
function statusOf(error: unknown): number | undefined {
return (error as { response?: { status?: number } })?.response?.status;
}
function errorMessage(error: unknown): string {
const axiosLike = error as {
response?: { data?: { message?: string } };
message?: string;
};
return (
axiosLike?.response?.data?.message ||
axiosLike?.message ||
"Unknown error occurred"
);
}
export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
children,
retryOptions,
@@ -61,171 +68,84 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
autoFetch = true,
onConfigLoaded,
}) => {
const isBlockingMode = bootstrapMode === "blocking";
const [config, setConfig] = useState<AppConfig | null>(initialConfig);
const [error, setError] = useState<string | null>(null);
// Track how many times we've attempted to fetch. useRef avoids re-renders that can trigger loops.
const fetchCountRef = React.useRef(0);
const [hasResolvedConfig, setHasResolvedConfig] = useState(
Boolean(initialConfig) && !isBlockingMode,
);
const [loading, setLoading] = useState(!hasResolvedConfig);
const maxRetries = retryOptions?.maxRetries ?? 0;
const initialDelay = retryOptions?.initialDelay ?? 1000;
// Non-blocking mode treats initialConfig as good enough to render on.
const seeded = Boolean(initialConfig) && bootstrapMode !== "blocking";
const onConfigLoadedRef = React.useRef(onConfigLoaded);
onConfigLoadedRef.current = onConfigLoaded;
const maxRetries = retryOptions?.maxRetries ?? 0;
const initialDelay = retryOptions?.initialDelay ?? 1000;
const queryClient = useQueryClient();
// Auth pages skip the fetch until sign-in asks for one.
const [signedIn, setSignedIn] = useState(false);
// fetchQuery, not refetchQueries: the latter skips a disabled query, and
// enabling one whose cache is still fresh doesn't fetch either. Sign-in has
// to force the request — a pre-login 401 leaves a cached default behind.
const refetch = useCallback(async () => {
setSignedIn(true);
await queryClient.fetchQuery({
queryKey: qk.appConfig(),
queryFn: fetchAppConfig,
staleTime: 0,
});
}, [queryClient]);
const fetchConfig = useCallback(
async (force = false) => {
// Prevent duplicate fetches unless forced
if (!force && fetchCountRef.current > 0) {
console.debug("[AppConfig] Already fetched, skipping");
return;
}
const { isAuthPage } = useJwtConfigSync(refetch);
const fetching = autoFetch && (!isAuthPage || signedIn);
// Mark that we've attempted a fetch to prevent repeated auto-fetch loops
fetchCountRef.current += 1;
const shouldBlockUI = !hasResolvedConfig || isBlockingMode;
if (shouldBlockUI) {
setLoading(true);
}
setError(null);
const startTime = performance.now();
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const testConfig = getSimulatedAppConfig();
if (testConfig) {
setConfig(testConfig);
setHasResolvedConfig(true);
setLoading(false);
return;
}
if (attempt > 0) {
const delay = initialDelay * Math.pow(2, attempt - 1);
console.debug(
`[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`,
);
await sleep(delay);
}
// apiClient automatically adds JWT header if available via interceptors
// Always suppress error toast - we handle 401 errors locally
console.debug("[AppConfig] Fetching app config", {
attempt,
force,
path: window.location.pathname,
});
const response = await apiClient.get<AppConfig>(
"/api/v1/config/app-config",
{
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
);
const data = response.data;
console.debug("[AppConfig] Config fetched successfully:", data);
console.debug(
"[AppConfig] Fetch duration ms:",
(performance.now() - startTime).toFixed(2),
);
setConfig(data);
setHasResolvedConfig(true);
setLoading(false);
onConfigLoadedRef.current?.(data);
return; // Success - exit function
} catch (err: any) {
const status = err?.response?.status;
// On 401 (not authenticated), use default config with login enabled
// This allows the app to work even without authentication
if (status === 401) {
console.debug(
"[AppConfig] 401 error - using default config (login enabled)",
);
console.debug(
"[AppConfig] Fetch duration ms:",
(performance.now() - startTime).toFixed(2),
);
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
return;
}
// Check if we should retry (network errors or 5xx errors)
const shouldRetry =
(!status || status >= 500) && attempt < maxRetries;
if (shouldRetry) {
console.debug(
`[AppConfig] Attempt ${attempt + 1} failed (status ${status || "network error"}):`,
err.message,
"- will retry...",
);
continue;
}
// Final attempt failed or non-retryable error (4xx)
const errorMessage =
err?.response?.data?.message ||
err?.message ||
"Unknown error occurred";
setError(errorMessage);
console.error(
`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`,
err,
);
console.debug(
"[AppConfig] Fetch duration ms:",
(performance.now() - startTime).toFixed(2),
);
// Preserve existing config (initial default or previous fetch). If nothing is set, assume login enabled.
setConfig((current) => current ?? { enableLogin: true });
setHasResolvedConfig(true);
break;
}
}
setLoading(false);
const { data, error, isFetching } = useQuery({
queryKey: qk.appConfig(),
queryFn: fetchAppConfig,
enabled: fetching,
staleTime: CONFIG_STALE_TIME,
// Network and 5xx only; failureCount is 0-based, so `<` gives maxRetries retries.
retry: (failureCount, err) => {
const status = statusOf(err);
return (!status || status >= 500) && failureCount < maxRetries;
},
[hasResolvedConfig, isBlockingMode, maxRetries, initialDelay],
);
const { isAuthPage } = useJwtConfigSync(fetchConfig);
retryDelay: (attempt) => initialDelay * 2 ** attempt,
});
useEffect(() => {
if (isAuthPage) {
console.debug(
"[AppConfig] On auth page - using default config, skipping fetch",
{ path: window.location.pathname },
);
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
return;
}
if (error) console.error("[AppConfig] Failed to fetch app config:", error);
}, [error]);
if (autoFetch) {
fetchConfig();
}
}, [autoFetch, fetchConfig, isAuthPage]);
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
useEffect(() => {
if (data) onConfigLoadedRef.current?.(data);
}, [data]);
const value = useMemo<AppConfigContextValue>(
() => ({
config,
loading,
error,
config:
data ??
initialConfig ??
(isAuthPage || error ? DEFAULT_APP_CONFIG : null),
// "Config not settled yet": in flight, or never going to be fetched at
// all. isFetching rather than isPending because a pre-login 401 resolves
// to the default config, so isPending is already false by the time the
// post-sign-in fetch runs — and consumers key effects on the flip.
loading: seeded
? false
: !autoFetch
? true
: fetching
? isFetching
: false,
error: error ? errorMessage(error) : null,
refetch,
}),
[config, loading, error, refetch],
[
data,
error,
isFetching,
initialConfig,
isAuthPage,
seeded,
autoFetch,
fetching,
refetch,
],
);
return (
@@ -235,16 +155,10 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
);
};
/**
* Hook to access application configuration
* Must be used within AppConfigProvider
*/
export function useAppConfig(): AppConfigContextValue {
const context = useContext(AppConfigContext);
if (context === undefined) {
throw new Error("useAppConfig must be used within AppConfigProvider");
}
return context;
}
@@ -176,6 +176,9 @@ export interface ViewerContextType {
registerImmediatePanUpdate: (
callback: (isPanning: boolean) => void,
) => () => void;
registerImmediateRotationUpdate: (
callback: (rotation: number) => void,
) => () => void;
// Internal - for bridges to trigger immediate updates
triggerImmediateScrollUpdate: (
@@ -188,6 +191,7 @@ export interface ViewerContextType {
isDualPage?: boolean,
) => void;
triggerImmediatePanUpdate: (isPanning: boolean) => void;
triggerImmediateRotationUpdate: (rotation: number) => void;
// Action handlers - call EmbedPDF APIs directly
scrollActions: ScrollActions;
@@ -310,6 +314,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
register: registerImmediatePanUpdate,
trigger: triggerImmediatePanInternal,
} = useImmediateNotifier<[boolean]>();
const {
register: registerImmediateRotationUpdate,
trigger: triggerImmediateRotationInternal,
} = useImmediateNotifier<[number]>();
const triggerImmediateZoomUpdate = useCallback(
(percent: number) => {
@@ -339,6 +347,13 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
[triggerImmediatePanInternal],
);
const triggerImmediateRotationUpdate = useCallback(
(rotation: number) => {
triggerImmediateRotationInternal(rotation);
},
[triggerImmediateRotationInternal],
);
const registerBridge = useCallback(
<K extends BridgeKey>(
type: K,
@@ -638,10 +653,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
registerImmediateScrollUpdate,
registerImmediateSpreadUpdate,
registerImmediatePanUpdate,
registerImmediateRotationUpdate,
triggerImmediateScrollUpdate,
triggerImmediateZoomUpdate,
triggerImmediateSpreadUpdate,
triggerImmediatePanUpdate,
triggerImmediateRotationUpdate,
// Actions
scrollActions,
@@ -4,7 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { getToolOgImage } from "@app/data/ogImage";
// Build tooling (plain ESM, node:fs only) - import the helpers for coverage.
// eslint-disable-next-line no-restricted-imports -- build script lives outside the @app alias root
// oxlint-disable-next-line no-restricted-imports -- build script lives outside the @app alias root
import {
buildOgTags,
injectOg,
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { isAxiosError } from "axios";
import apiClient from "@app/services/apiClient";
import { alert } from "@app/components/toast";
import { fileStorage } from "@app/services/fileStorage";
@@ -333,8 +334,8 @@ export function useSigningSessionController(enabled: boolean) {
pdfFile = new File([pdfResponse.data], session.documentName, {
type: "application/pdf",
});
} catch (pdfError: any) {
if (pdfError?.response?.status === 404) {
} catch (pdfError) {
if (isAxiosError(pdfError) && pdfError.response?.status === 404) {
alert({
alertType: "warning",
title: t("certSign.sessions.pdfNotReady", "PDF Not Ready"),
@@ -13,9 +13,10 @@ import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
import { createFileFromApiResponse } from "@app/utils/fileResponseUtils";
import { getPdfiumModule, saveRawDocument } from "@app/services/pdfiumService";
import { copyRgbaToBgraHeap } from "@app/utils/pdfiumBitmapUtils";
import type { PDFDocumentProxy } from "pdfjs-dist";
async function renderPdfPageToCanvas(
pdf: any,
pdf: PDFDocumentProxy,
pageNumber: number,
scale: number,
): Promise<HTMLCanvasElement> {
@@ -26,7 +27,7 @@ async function renderPdfPageToCanvas(
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D context unavailable");
await page.render({ canvasContext: ctx, viewport }).promise;
await page.render({ canvasContext: ctx, canvas, viewport }).promise;
return canvas;
}
@@ -665,7 +665,7 @@ export const useCompareOperation = (): CompareOperationHook => {
if (workerRef.current) {
try {
workerRef.current.terminate();
// eslint-disable-next-line no-empty
// oxlint-disable-next-line no-empty
} catch {}
workerRef.current = null;
}
@@ -210,8 +210,8 @@ export const buildConvertFormData = (
// Static function that can be used by both the hook and automation executor
export const createFileFromResponse = (
responseData: any,
headers: any,
responseData: Blob,
headers: Record<string, unknown>,
originalFileName: string,
targetExtension: string,
): File => {
@@ -85,7 +85,7 @@ describe("useRemovePasswordOperation", () => {
const testFile = new File(["test content"], "test.pdf", {
type: "application/pdf",
});
const formData = buildFormData(testParameters, testFile as any);
const formData = buildFormData(testParameters, testFile);
// Verify the form data contains the file
expect(formData.get("fileInput")).toBe(testFile);
@@ -0,0 +1,293 @@
import {
useCallback,
useEffect,
useRef,
useState,
type RefObject,
} from "react";
import type {
Measurement,
MeasureScale,
PageMeasureScales,
} from "@app/utils/measurementTypes";
import type { RulerOverlayHandle } from "@app/components/viewer/RulerOverlay";
import {
loadSessionMap,
saveSessionMap,
validateMeasureScale,
validateMeasurement,
} from "@app/utils/measurementUtils";
import type { StirlingFile } from "@app/types/fileContext";
import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext";
import { extractPageMeasureScales } from "@app/utils/pdfMeasurementExtraction";
import type { ScaleCalibrationMeasurement } from "@app/components/viewer/ScaleCalibrationDialog";
// ─── Hook: useMeasurementManager ──────────────────────────────────────────────
interface EffectiveFileLike {
file: Blob | File;
url: string | null;
}
type ViewerFile = StirlingFile | File | null | undefined;
interface UseMeasurementManagerProps {
currentFile: ViewerFile;
effectiveFile: EffectiveFileLike | null | undefined;
rulerOverlayRef: RefObject<RulerOverlayHandle | null>;
}
interface UseMeasurementManagerReturn {
isRulerActive: boolean;
setIsRulerActive: (v: boolean) => void;
pageMeasureScales: PageMeasureScales | null;
customScale: MeasureScale | null;
handleSetCustomScale: (scale: MeasureScale | null) => void;
isScaleCalibrationActive: boolean;
scaleCalibrationMeasurement: ScaleCalibrationMeasurement | null;
startScaleCalibration: () => void;
cancelScaleCalibration: () => void;
handleScaleCalibrationMeasurement: (
measurement: ScaleCalibrationMeasurement,
) => void;
applyScaleCalibration: (scale: MeasureScale) => void;
}
export function useMeasurementManager({
currentFile,
effectiveFile,
rulerOverlayRef,
}: UseMeasurementManagerProps): UseMeasurementManagerReturn {
const [isRulerActive, setIsRulerActive] = useState(false);
const [pageMeasureScales, setPageMeasureScales] =
useState<PageMeasureScales | null>(null);
const [customScale, setCustomScale] = useState<MeasureScale | null>(null);
const [isScaleCalibrationActive, setIsScaleCalibrationActive] =
useState(false);
const [scaleCalibrationMeasurement, setScaleCalibrationMeasurement] =
useState<ScaleCalibrationMeasurement | null>(null);
const [scalesByFileId, setScalesByFileId] = useState<
Map<string, MeasureScale | null>
>(new Map());
const [measurementsByFileId, setMeasurementsByFileId] = useState<
Map<string, Measurement[]>
>(new Map());
const restoredFileKeyRef = useRef<string | null>(null);
const getStableFileKey = useCallback((file: ViewerFile): string | null => {
if (!file) return null;
if (isStirlingFile(file)) {
return file.fileId;
}
return getFormFillFileId(file);
}, []);
const currentFileKey = getStableFileKey(currentFile);
function persistSessionValue(
storageKey: string,
fileKey: string,
value: MeasureScale | Measurement[] | null,
label: string,
) {
try {
saveSessionMap(storageKey, fileKey, value);
} catch (error) {
console.error(`[Measurement] Failed to persist ${label}:`, error);
}
}
function readStoredScale(fileKey: string): MeasureScale | null | undefined {
const storedMap = loadSessionMap("stirling_scales");
if (!(fileKey in storedMap)) {
return undefined;
}
const storedValue = storedMap[fileKey];
return validateMeasureScale(storedValue) ? storedValue : null;
}
function readStoredMeasurements(fileKey: string): Measurement[] | undefined {
const storedMap = loadSessionMap("stirling_measurements");
if (!(fileKey in storedMap)) {
return undefined;
}
const storedValue = storedMap[fileKey];
if (!Array.isArray(storedValue)) {
return [];
}
return storedValue.filter((measurement) =>
validateMeasurement(measurement),
);
}
function persistScale(fileKey: string, scale: MeasureScale | null) {
persistSessionValue("stirling_scales", fileKey, scale, "scale");
}
function persistMeasurements(fileKey: string, value: Measurement[]) {
persistSessionValue(
"stirling_measurements",
fileKey,
value,
"measurements",
);
}
const handleSetCustomScale = useCallback(
(scale: MeasureScale | null) => {
const fileKey = currentFileKey;
if (fileKey) {
setScalesByFileId((prev) => new Map(prev).set(fileKey, scale));
persistScale(fileKey, scale);
}
setCustomScale(scale);
setScaleCalibrationMeasurement(null);
setIsScaleCalibrationActive(false);
},
[currentFileKey],
);
const handleSetRulerActive = useCallback((active: boolean) => {
setIsRulerActive(active);
if (!active) {
setScaleCalibrationMeasurement(null);
setIsScaleCalibrationActive(false);
}
}, []);
const startScaleCalibration = useCallback(() => {
setScaleCalibrationMeasurement(null);
setIsScaleCalibrationActive(true);
setIsRulerActive(true);
}, []);
const cancelScaleCalibration = useCallback(() => {
setScaleCalibrationMeasurement(null);
setIsScaleCalibrationActive(false);
}, []);
const handleScaleCalibrationMeasurement = useCallback(
(measurement: ScaleCalibrationMeasurement) => {
setScaleCalibrationMeasurement(measurement);
setIsScaleCalibrationActive(false);
},
[],
);
const applyScaleCalibration = useCallback(
(scale: MeasureScale) => {
handleSetCustomScale(scale);
setScaleCalibrationMeasurement(null);
setIsScaleCalibrationActive(false);
},
[handleSetCustomScale],
);
useEffect(() => {
if (!currentFileKey) {
setPageMeasureScales(null);
setCustomScale(null);
setScaleCalibrationMeasurement(null);
setIsScaleCalibrationActive(false);
setIsRulerActive(false);
rulerOverlayRef.current?.clearAll(true);
restoredFileKeyRef.current = null;
return;
}
if (restoredFileKeyRef.current === currentFileKey) {
return;
}
restoredFileKeyRef.current = currentFileKey;
setScaleCalibrationMeasurement(null);
setIsScaleCalibrationActive(false);
const storedScale = readStoredScale(currentFileKey);
const savedScale =
storedScale === undefined
? (scalesByFileId.get(currentFileKey) ?? null)
: storedScale;
setCustomScale(savedScale);
const storedMeasurements = readStoredMeasurements(currentFileKey);
const savedMeasurements =
storedMeasurements === undefined
? (measurementsByFileId.get(currentFileKey) ?? [])
: storedMeasurements;
rulerOverlayRef.current?.clearAll(true);
rulerOverlayRef.current?.restoreMeasurements(savedMeasurements);
}, [currentFileKey, measurementsByFileId, rulerOverlayRef, scalesByFileId]);
useEffect(() => {
const fileBlob = effectiveFile?.file;
if (!fileBlob || !currentFileKey) {
setPageMeasureScales(null);
return;
}
setPageMeasureScales(null);
let cancelled = false;
extractPageMeasureScales(fileBlob)
.then((scales) => {
if (!cancelled) {
setPageMeasureScales(scales);
}
})
.catch((error) => {
if (!cancelled) {
console.warn("[Measurement] Failed to load PDF scales", error);
setPageMeasureScales(null);
}
});
return () => {
cancelled = true;
};
}, [currentFileKey, effectiveFile?.file]);
useEffect(() => {
if (!rulerOverlayRef.current || !currentFileKey) return;
const unsubscribe = rulerOverlayRef.current.onMeasurementsChange(
(newMeasurements: Measurement[]) => {
const validMeasurements = newMeasurements.filter((measurement) =>
validateMeasurement(measurement),
);
setMeasurementsByFileId((prev) =>
new Map(prev).set(currentFileKey, validMeasurements),
);
persistMeasurements(currentFileKey, validMeasurements);
},
);
return () => {
if (typeof unsubscribe === "function") {
unsubscribe();
}
};
}, [currentFileKey, rulerOverlayRef]);
return {
isRulerActive,
setIsRulerActive: handleSetRulerActive,
pageMeasureScales,
customScale,
handleSetCustomScale,
isScaleCalibrationActive,
scaleCalibrationMeasurement,
startScaleCalibration,
cancelScaleCalibration,
handleScaleCalibrationMeasurement,
applyScaleCalibration,
};
}
@@ -2,6 +2,7 @@ import { ReactNode } from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
const posthogState = vi.hoisted(() => ({ loaded: false }));
const posthogMock = vi.hoisted(() => ({
@@ -40,13 +41,15 @@ describe("usePosthogTracking", () => {
it("does not initialize PostHog when analytics is disabled", async () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<AppConfigProvider
initialConfig={{ enableAnalytics: false }}
bootstrapMode="non-blocking"
autoFetch={false}
>
{children}
</AppConfigProvider>
<TestQueryProvider>
<AppConfigProvider
initialConfig={{ enableAnalytics: false }}
bootstrapMode="non-blocking"
autoFetch={false}
>
{children}
</AppConfigProvider>
</TestQueryProvider>
);
renderHook(() => usePosthogTracking(), { wrapper });
@@ -58,13 +61,15 @@ describe("usePosthogTracking", () => {
it("initializes PostHog when analytics is enabled", async () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<AppConfigProvider
initialConfig={{ enableAnalytics: true }}
bootstrapMode="non-blocking"
autoFetch={false}
>
{children}
</AppConfigProvider>
<TestQueryProvider>
<AppConfigProvider
initialConfig={{ enableAnalytics: true }}
bootstrapMode="non-blocking"
autoFetch={false}
>
{children}
</AppConfigProvider>
</TestQueryProvider>
);
renderHook(() => usePosthogTracking(), { wrapper });
+1
View File
@@ -1,5 +1,6 @@
/** Editor query keys: ["editor", <resource>, ...params]. */
export const qk = {
appConfig: () => ["editor", "appConfig"] as const,
footerInfo: () => ["editor", "footerInfo"] as const,
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
users: () => ["editor", "users"] as const,
@@ -0,0 +1,146 @@
import { describe, expect, test, afterEach, beforeEach, vi } from "vitest";
import "fake-indexeddb/auto";
import { expectConsole } from "@app/tests/failOnConsole";
/**
* Regression test for the WebKit nightly breakage introduced with the
* large-file OOM fix (#7175): `storeStirlingFile` began putting the `File`
* itself into IndexedDB (persisted by reference, so multi-GB uploads never
* materialize in JS memory). WebKit refuses blob values whenever it can't write
* the blob's backing file and rejects the request with `UnknownError: Error
* preparing Blob/File data to be stored in object store`, so on WebKit every
* upload silently failed to persist: files vanished on navigation, Compare
* slots never filled, and the classification backfill had no bytes to read.
*
* The service now retries such a rejection with an ArrayBuffer copy and stops
* offering blobs for the rest of the session.
*/
const nativeAdd = IDBObjectStore.prototype.add;
/** What each `add` attempt carried in `data` — the blob path or the copy path. */
let attempts: Array<"blob" | "copy"> = [];
/** An IDBRequest that fails asynchronously, the way WebKit rejects blob puts. */
class FailingRequest extends EventTarget {
onerror: ((event: Event) => void) | null = null;
onsuccess: ((event: Event) => void) | null = null;
constructor(readonly error: DOMException) {
super();
queueMicrotask(() => this.onerror?.(new Event("error")));
}
}
/**
* Record every add attempt, optionally failing the blob-valued ones the way an
* engine without blob storage does.
*/
function instrumentAdd(options: { rejectBlobs: boolean }) {
IDBObjectStore.prototype.add = function (
this: IDBObjectStore,
value: unknown,
key?: IDBValidKey,
) {
const isBlob = (value as { data?: unknown } | null)?.data instanceof Blob;
attempts.push(isBlob ? "blob" : "copy");
if (isBlob && options.rejectBlobs) {
return new FailingRequest(
new DOMException(
"Error preparing Blob/File data to be stored in object store",
"UnknownError",
),
) as unknown as IDBRequest<IDBValidKey>;
}
return key === undefined
? nativeAdd.call(this, value)
: nativeAdd.call(this, value, key);
} as typeof IDBObjectStore.prototype.add;
}
/**
* A fresh service per test: whether the engine accepts blobs is remembered for
* the process lifetime by design, so tests must not inherit that decision from
* each other.
*/
async function freshFileStorage() {
vi.resetModules();
const [{ fileStorage }, { createStirlingFile, createNewStirlingFileStub }] =
await Promise.all([
import("@app/services/fileStorage"),
import("@app/types/fileContext"),
]);
const store = async (name: string) => {
const file = new File(["%PDF-1.7 stirling"], name, {
type: "application/pdf",
});
const stub = createNewStirlingFileStub(file);
await fileStorage.storeStirlingFile(
createStirlingFile(file, stub.id),
stub,
);
return stub.id;
};
return { fileStorage, store };
}
beforeEach(() => {
attempts = [];
});
afterEach(() => {
IDBObjectStore.prototype.add = nativeAdd;
});
describe("storeStirlingFile — blob-value fallback", () => {
test("stores the File by reference when the engine accepts blob values", async () => {
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
const id = await store("by-reference.pdf");
expect(attempts).toEqual(["blob"]);
expect((await fileStorage.getStirlingFile(id))?.name).toBe(
"by-reference.pdf",
);
});
test("falls back to a copy when the engine rejects blob values, and the file stays readable", async () => {
expectConsole.warn(/IndexedDB rejected a Blob value/);
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: true });
const id = await store("webkit.pdf");
expect(attempts).toEqual(["blob", "copy"]);
// Readable back is what every downstream consumer depends on: rehydration
// after navigation, thumbnails, the classification backfill.
expect((await fileStorage.getStirlingFile(id))?.name).toBe("webkit.pdf");
});
test("remembers the rejection, so later files skip the doomed blob attempt", async () => {
expectConsole.warn(/IndexedDB rejected a Blob value/);
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: true });
await store("first.pdf");
attempts = [];
const id = await store("second.pdf");
// Straight to the copy path — no repeated blob probe, and only the single
// warning expected above.
expect(attempts).toEqual(["copy"]);
expect((await fileStorage.getStirlingFile(id))?.name).toBe("second.pdf");
});
test("does not retry a failure a copy can't fix (quota)", async () => {
const { store } = await freshFileStorage();
IDBObjectStore.prototype.add = function (this: IDBObjectStore) {
attempts.push("blob");
throw new DOMException("no space left", "QuotaExceededError");
} as typeof IDBObjectStore.prototype.add;
await expect(store("too-big.pdf")).rejects.toThrow(/no space left/);
expect(attempts).toEqual(["blob"]);
});
});

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