mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'main' into fix_css_20260729
This commit is contained in:
+23
-13
@@ -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
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"LaserKaspar",
|
||||
"sbplat",
|
||||
"reecebrowne",
|
||||
"DarioGii",
|
||||
"ConnorYoh",
|
||||
"EthanHealy01",
|
||||
"jbrunton96",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+15
-13
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-8
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
@@ -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()
|
||||
}
|
||||
}
|
||||
Generated
+3
-3
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+151
-151
@@ -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,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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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: {} }),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+7
-4
@@ -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 => {
|
||||
|
||||
+1
-1
@@ -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,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
Reference in New Issue
Block a user