diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index d509b404a1..6a9a01d41d 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -12,6 +12,10 @@ on: default: 'development' type: string secrets: + PUSH_O_MATIC_APP_CLIENT_ID: + required: true + PUSH_O_MATIC_APP_KEY: + required: true KEY_JKS: required: true ALIAS: @@ -59,7 +63,7 @@ jobs: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4 + uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -94,7 +98,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile @@ -106,7 +110,7 @@ jobs: working-directory: ./mobile run: printf "%s" $KEY_JKS | base64 -d > android/key.jks - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'zulu' java-version: '17' @@ -216,11 +220,18 @@ jobs: persist-credentials: false - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile + - name: Setup Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: ./mobile/ios + - name: Install dependencies run: mise //mobile:install:ci @@ -231,18 +242,6 @@ jobs: working-directory: ./mobile run: flutter build ios --config-only --no-codesign - - name: Setup Ruby - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 - with: - ruby-version: '3.3' - bundler-cache: true - working-directory: ./mobile/ios - - - name: Install CocoaPods dependencies - working-directory: ./mobile/ios - run: | - pod install - - name: Create API Key env: API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 7cbab78069..522444db97 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@4bdf89062dad92caea71236f59704b04d3ce9682 # v0.1.8 + uses: oasdiff/oasdiff-action/breaking@b7c3adeb54330db1903d27c61db520e5661ad55b # v0.1.11 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json @@ -43,7 +43,7 @@ jobs: persist-credentials: false - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ github.token }} working_directory: ./mobile diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index aa2c6297b2..7cfa4bbc92 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -44,7 +44,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -83,7 +83,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index cf932dac8d..a1ec4e80c8 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -39,7 +39,7 @@ jobs: needs: [get_body, should_run] if: ${{ needs.should_run.outputs.should_run == 'true' }} container: - image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a + image: ghcr.io/immich-app/mdq:main@sha256:74b93dfbf13f8ee73eb35d8d5195ff667f93f9bc55df5ae453dd43a461c821fa outputs: checked: ${{ steps.get_checkbox.outputs.checked }} steps: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9bf5abfcba..522de204ea 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -71,7 +71,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -84,6 +84,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ab1ec84136..90ba3f40d6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,6 +14,10 @@ concurrency: permissions: {} +env: + GHCR_NAMESPACE: ghcr.io/${{ github.repository_owner }} + DOCKERHUB_NAMESPACE: docker.io/altran1502 + jobs: pre-job: runs-on: ubuntu-latest @@ -31,7 +35,7 @@ jobs: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4 + uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -44,8 +48,6 @@ jobs: - 'machine-learning/**' force-filters: | - '.github/workflows/docker.yml' - - '.github/workflows/multi-runner-build.yml' - - '.github/actions/image-build' force-events: 'workflow_dispatch,release' retag_ml: @@ -58,10 +60,33 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] + include: &ml-variants + - image: immich-machine-learning + device: cpu + - image: immich-machine-learning + device: cuda + suffixes: '-cuda' + platforms: linux/amd64 + - image: immich-machine-learning + device: openvino + suffixes: '-openvino' + platforms: linux/amd64 + - image: immich-machine-learning + device: armnn + suffixes: '-armnn' + platforms: linux/arm64 + - image: immich-machine-learning + device: rknn + suffixes: '-rknn' + platforms: linux/arm64 + - image: immich-machine-learning + device: rocm + suffixes: '-rocm' + platforms: linux/amd64 + runner-mapping: '{"linux/amd64": "pokedex-large"}' steps: - name: Login to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -70,10 +95,10 @@ jobs: - name: Re-tag image env: REGISTRY_NAME: 'ghcr.io' - REPOSITORY: ${{ github.repository_owner }}/immich-machine-learning - TAG_OLD: main${{ matrix.suffix }} - TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} - TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }} + REPOSITORY: ${{ github.repository_owner }}/${{ matrix.image }} + TAG_OLD: main${{ matrix.suffixes }} + TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffixes }} + TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffixes }} run: | docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}" docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}" @@ -86,12 +111,9 @@ jobs: packages: write if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest - strategy: - matrix: - suffix: [''] steps: - name: Login to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -101,9 +123,9 @@ jobs: env: REGISTRY_NAME: 'ghcr.io' REPOSITORY: ${{ github.repository_owner }}/immich-server - TAG_OLD: main${{ matrix.suffix }} - TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} - TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }} + TAG_OLD: main + TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }} + TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }} run: | docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}" docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}" @@ -115,40 +137,27 @@ jobs: strategy: fail-fast: false matrix: - include: - - device: cpu - - device: cuda - suffixes: '-cuda' - platforms: linux/amd64 - - device: openvino - suffixes: '-openvino' - platforms: linux/amd64 - - device: armnn - suffixes: '-armnn' - platforms: linux/arm64 - - device: rknn - suffixes: '-rknn' - platforms: linux/arm64 - - device: rocm - suffixes: '-rocm' - platforms: linux/amd64 - runner-mapping: '{"linux/amd64": "pokedex-large"}' - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@50dc3a14f0606ecd8fc28d78d3a3c655115ab695 # multi-runner-build-workflow-v3.1.0 + include: *ml-variants + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@4d420cf8e5ea499f99b2c2849ff9a219d5f7b178 # multi-runner-build-workflow-v4.0.0 permissions: contents: read actions: read packages: write - secrets: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} with: - image: immich-machine-learning + image: ${{ matrix.image }} context: machine-learning dockerfile: machine-learning/Dockerfile platforms: ${{ matrix.platforms }} runner-mapping: ${{ matrix.runner-mapping }} suffixes: ${{ matrix.suffixes }} - dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }} + tags: &tag-rules | + type=ref,event=branch + type=ref,event=pr + type=sha,format=long,prefix=commit- + type=semver,pattern={{version}},prefix=v + type=semver,pattern={{major}},prefix=v + type=match,pattern=v(\d+),group=1,prefix=v,suffix=-rc,enable=${{ github.event.release.prerelease == true }} + type=raw,value=release,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }} build-args: | DEVICE=${{ matrix.device }} @@ -156,25 +165,72 @@ jobs: name: Build and Push Server needs: pre-job if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }} - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@50dc3a14f0606ecd8fc28d78d3a3c655115ab695 # multi-runner-build-workflow-v3.1.0 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@4d420cf8e5ea499f99b2c2849ff9a219d5f7b178 # multi-runner-build-workflow-v4.0.0 permissions: contents: read actions: read packages: write - secrets: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} with: image: immich-server context: . dockerfile: server/Dockerfile - dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }} + tags: *tag-rules build-args: | DEVICE=cpu + mirror: + name: Mirror to Docker Hub + needs: [machine-learning, server] + if: ${{ github.event_name == 'release' && github.event.release.prerelease == false }} + permissions: + packages: read + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Slightly hacky; we reuse the ml matrix but tack on immich-server too + image: [immich-server] + include: *ml-variants + env: + IMAGE: ${{ matrix.image }} + SUFFIX: ${{ matrix.suffixes }} + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Generate docker image tags + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + flavor: | + latest=false + images: ${{ env.GHCR_NAMESPACE }}/${{ env.IMAGE }} + tags: *tag-rules + + - name: Mirror tags to Docker Hub + env: + TAGS: ${{ steps.meta.outputs.tags }} + run: | + for tag in ${TAGS}; do + version="${tag##*:}" + docker buildx imagetools create \ + -t "${DOCKERHUB_NAMESPACE}/${IMAGE}:${version}${SUFFIX}" \ + "${GHCR_NAMESPACE}/${IMAGE}:${version}${SUFFIX}" + done + success-check-server: name: Docker Build & Push Server Success - needs: [server, retag_server] + needs: [server, retag_server, mirror] permissions: {} runs-on: ubuntu-latest if: always() @@ -185,7 +241,7 @@ jobs: success-check-ml: name: Docker Build & Push ML Success - needs: [machine-learning, retag_ml] + needs: [machine-learning, retag_ml, mirror] permissions: {} runs-on: ubuntu-latest if: always() diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 09c340e0fe..e0c5de3fab 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -29,7 +29,7 @@ jobs: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4 + uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -68,7 +68,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index a452e2ae51..b88e3f56dc 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -143,7 +143,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index eec66484d3..cdb3f83c96 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -31,7 +31,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 4c97f3f16c..2638c31fca 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -30,7 +30,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml index abbcfaebd0..5820a8b284 100644 --- a/.github/workflows/pr-label-validation.yml +++ b/.github/workflows/pr-label-validation.yml @@ -8,7 +8,6 @@ permissions: {} jobs: validate-release-label: - if: ${{ !startsWith(github.event.pull_request.head.ref, 'mergify/merge-queue/') }} runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 5150896192..abc84c7cdb 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -17,10 +17,6 @@ on: - prepatch - prerelease - release - mobileBump: - description: 'Bump mobile build number' - required: false - type: boolean skipTranslations: description: 'Skip translations' required: false @@ -68,7 +64,7 @@ jobs: ref: main - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -79,8 +75,7 @@ jobs: - name: Bump version env: RELEASE_TYPE: ${{ inputs.releaseType }} - MOBILE_BUMP: ${{ inputs.mobileBump }} - run: mise //:release --type "${RELEASE_TYPE}" --mobile "${MOBILE_BUMP}" + run: mise //:release --type "${RELEASE_TYPE}" - id: output run: | @@ -107,6 +102,8 @@ jobs: contents: read pull-requests: write secrets: + PUSH_O_MATIC_APP_CLIENT_ID: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} + PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }} KEY_JKS: ${{ secrets.KEY_JKS }} ALIAS: ${{ secrets.ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} @@ -152,19 +149,23 @@ jobs: github-token: ${{ steps.generate-token.outputs.token }} - name: Create draft release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - draft: true - prerelease: ${{ needs.bump_version.outputs.rc }} - tag_name: ${{ needs.bump_version.outputs.version }} - token: ${{ steps.generate-token.outputs.token }} - generate_release_notes: true - body_path: misc/release/notes.tmpl - files: | - docker/docker-compose.yml - docker/docker-compose.rootless.yml - docker/example.env - docker/hwaccel.ml.yml - docker/hwaccel.transcoding.yml - docker/prometheus.yml + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + REPO: ${{ github.repository }} + TAG: ${{ needs.bump_version.outputs.version }} + PRERELEASE: ${{ needs.bump_version.outputs.rc }} + run: | + gh release create "${TAG}" \ + --repo "${REPO}" \ + --draft \ + --verify-tag \ + --prerelease="${PRERELEASE}" \ + --notes-file misc/release/notes.tmpl \ + --generate-notes \ + docker/docker-compose.yml \ + docker/docker-compose.rootless.yml \ + docker/example.env \ + docker/hwaccel.ml.yml \ + docker/hwaccel.transcoding.yml \ + docker/prometheus.yml \ *.apk diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index a5eaf26181..9c6101e4e1 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -29,7 +29,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 8fe1e1265c..0a3c61668c 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -28,7 +28,7 @@ jobs: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4 + uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -64,7 +64,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a01b429713..cb3448e870 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4 + uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -91,7 +91,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -120,7 +120,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -152,7 +152,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -184,7 +184,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -229,7 +229,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -268,7 +268,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -297,7 +297,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -348,7 +348,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -382,7 +382,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -419,7 +419,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -498,7 +498,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -606,7 +606,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile @@ -644,7 +644,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -676,7 +676,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -729,7 +729,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -787,7 +787,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml index 9447f4dea0..94a81f8f5d 100644 --- a/.github/workflows/weblate-lock.yml +++ b/.github/workflows/weblate-lock.yml @@ -32,7 +32,7 @@ jobs: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4 + uses: immich-app/devtools/actions/pre-job@03514eceb4a27cd714976a866b350df5ce175720 # pre-job-action-v2.1.0 with: github-token: ${{ steps.token.outputs.token }} filters: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f2c9e62a0..5972872528 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,8 @@ We ask you not to open PRs generated with an LLM. We find that code generated li If you use an LLM to translate a PR description or title, that is fine. Please make sure however that you stick to the PR template and that the text is written concisely. +LLMs must not be used to fix GitHub issues labelled good first issue. These issues are generally not urgent, and are intended to be learning opportunities for new contributors to get familiar with the codebase. Whether you are a newcomer or not, fully automating the process of fixing this issue squanders the learning opportunity and doesn’t add much value to the project. Using LLMs to fix issues labelled as “good first issue” is forbidden, and those PRs will be closed automatically. + Misrepresenting LLM use, contribution farming (automated low-effort PRs), or repeatedly hitting auto-close rules may be grounds for a block at maintainer discretion. ## Feature freezes diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 013ff266fa..b717de02f9 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -85,7 +85,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:3c42b892cf723fa54d2f262c37a0e1f80aa8c8ddb1da7b9b0df9455a35a7f893 + image: prom/prometheus@sha256:508729e0e2d18e11fd742a5a5ca70e557b940a93948c3c95fd0123a6fd538b69 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus @@ -97,7 +97,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:12.4.6-ubuntu@sha256:35c75489ff2e2e69c53977a180de491643d51fdb32108f9335317527978daed7 + image: grafana/grafana:12.4.7-ubuntu@sha256:d327f509400334fbea74a282d287f20c3187222741ec99e76f253cac16c90129 volumes: - grafana-data:/var/lib/grafana diff --git a/docs/mise.toml b/docs/mise.toml index c4fe5f1b68..da5d00940e 100644 --- a/docs/mise.toml +++ b/docs/mise.toml @@ -28,4 +28,4 @@ run = "prettier --write ." run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}" [tools] -wrangler = "4.114.0" +wrangler = "4.119.0" diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index 739d7bd001..967c85c780 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -2,6 +2,8 @@ import { useWindowSize } from '@docusaurus/theme-common'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; import React, { useEffect, useState } from 'react'; +const ARCHIVED_VERSIONS_URL = 'https://version.immich.cloud/v1/docs/versions'; + export default function VersionSwitcher(): JSX.Element { const [versions, setVersions] = useState([]); const [activeLabel, setLabel] = useState('Versions'); @@ -11,12 +13,7 @@ export default function VersionSwitcher(): JSX.Element { useEffect(() => { async function getVersions() { try { - let baseUrl = 'https://docs.immich.app'; - if (window.location.origin === 'http://localhost:3005') { - baseUrl = window.location.origin; - } - - const response = await fetch(`${baseUrl}/archived-versions.json`); + const response = await fetch(ARCHIVED_VERSIONS_URL); const archiveVersions = await response.json(); diff --git a/docs/static/_redirects b/docs/static/_redirects index 633025ac17..e218124e59 100644 --- a/docs/static/_redirects +++ b/docs/static/_redirects @@ -1,3 +1,4 @@ +/archived-versions.json https://version.immich.cloud/v1/docs/versions 307 / /overview/quick-start 307 /mobile-app-beta-program /features/mobile-app 307 /contribution-guidelines /overview/support-the-project#contributing 307 diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json deleted file mode 100644 index dbeed76b73..0000000000 --- a/docs/static/archived-versions.json +++ /dev/null @@ -1,265 +0,0 @@ -[ - { - "label": "v3.1.0", - "url": "https://docs.v3.1.0.archive.immich.app" - }, - { - "label": "v3.0.3", - "url": "https://docs.v3.0.3.archive.immich.app" - }, - { - "label": "v2.7.5", - "url": "https://docs.v2.7.5.archive.immich.app" - }, - { - "label": "v2.6.3", - "url": "https://docs.v2.6.3.archive.immich.app" - }, - { - "label": "v2.5.6", - "url": "https://docs.v2.5.6.archive.immich.app" - }, - { - "label": "v2.4.1", - "url": "https://docs.v2.4.1.archive.immich.app" - }, - { - "label": "v2.3.1", - "url": "https://docs.v2.3.1.archive.immich.app" - }, - { - "label": "v2.2.3", - "url": "https://docs.v2.2.3.archive.immich.app" - }, - { - "label": "v2.1.0", - "url": "https://docs.v2.1.0.archive.immich.app" - }, - { - "label": "v2.0.1", - "url": "https://docs.v2.0.1.archive.immich.app" - }, - { - "label": "v1.144.1", - "url": "https://docs.v1.144.1.archive.immich.app" - }, - { - "label": "v1.143.1", - "url": "https://docs.v1.143.1.archive.immich.app" - }, - { - "label": "v1.142.1", - "url": "https://v1.142.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.141.1", - "url": "https://v1.141.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.140.1", - "url": "https://v1.140.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.139.4", - "url": "https://v1.139.4.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.138.1", - "url": "https://v1.138.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.137.3", - "url": "https://v1.137.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.136.0", - "url": "https://v1.136.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.135.3", - "url": "https://v1.135.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.134.0", - "url": "https://v1.134.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.133.1", - "url": "https://v1.133.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.132.3", - "url": "https://v1.132.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.131.3", - "url": "https://v1.131.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.130.3", - "url": "https://v1.130.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.129.0", - "url": "https://v1.129.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.128.0", - "url": "https://v1.128.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.127.0", - "url": "https://v1.127.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.126.1", - "url": "https://v1.126.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.125.7", - "url": "https://v1.125.7.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.124.2", - "url": "https://v1.124.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.123.0", - "url": "https://v1.123.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.122.3", - "url": "https://v1.122.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.121.0", - "url": "https://v1.121.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.120.2", - "url": "https://v1.120.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.119.1", - "url": "https://v1.119.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.118.2", - "url": "https://v1.118.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.117.0", - "url": "https://v1.117.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.116.2", - "url": "https://v1.116.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.115.0", - "url": "https://v1.115.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.114.0", - "url": "https://v1.114.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.113.1", - "url": "https://v1.113.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.112.1", - "url": "https://v1.112.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.111.0", - "url": "https://v1.111.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.110.0", - "url": "https://v1.110.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.109.2", - "url": "https://v1.109.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.108.0", - "url": "https://v1.108.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.107.2", - "url": "https://v1.107.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.106.4", - "url": "https://v1.106.4.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.105.1", - "url": "https://v1.105.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.104.0", - "url": "https://v1.104.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.103.1", - "url": "https://v1.103.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.102.3", - "url": "https://v1.102.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.101.0", - "url": "https://v1.101.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.100.0", - "url": "https://v1.100.0.archive.immich.app", - "rootPath": "/docs" - } -] diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index d5da8f8445..3d69d1f503 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/e2e/src/api/specs/duplicate.e2e-spec.ts b/e2e/src/api/specs/duplicate.e2e-spec.ts deleted file mode 100644 index d6d0ec1394..0000000000 --- a/e2e/src/api/specs/duplicate.e2e-spec.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { LoginResponseDto } from '@immich/sdk'; -import { createUserDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; -import { app, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; - -describe('/duplicates', () => { - let admin: LoginResponseDto; - let user1: LoginResponseDto; - let user2: LoginResponseDto; - - beforeAll(async () => { - await utils.resetDatabase(); - - admin = await utils.adminSetup(); - - [user1, user2] = await Promise.all([ - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - ]); - }); - - beforeEach(async () => { - // Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs - // Note: We don't reset users since they're set up once in beforeAll - // Stack must be reset before asset due to foreign key constraint - await utils.resetDatabase(['stack', 'asset', 'album', 'tag']); - }); - - describe('GET /duplicates', () => { - it('should return empty array when no duplicates', async () => { - const { status, body } = await request(app) - .get('/duplicates') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - it('should return duplicate groups with suggestedKeepAssetIds', async () => { - // Create assets with different file sizes for duplicate detection - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Manually set duplicateId on both assets to create a duplicate group - const duplicateId = '00000000-0000-4000-8000-000000000001'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .get('/duplicates') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([ - { - duplicateId, - assets: expect.arrayContaining([ - expect.objectContaining({ id: asset1.id }), - expect.objectContaining({ id: asset2.id }), - ]), - suggestedKeepAssetIds: expect.any(Array), - }, - ]); - expect(body[0].suggestedKeepAssetIds.length).toBe(1); - }); - }); - - describe('POST /duplicates/resolve', () => { - it('should require authentication', async () => { - const { status, body } = await request(app) - .post('/duplicates/resolve') - .send({ - groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }], - }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should return failure for non-existent duplicate group', async () => { - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }], - }); - - expect(status).toBe(200); - expect(body).toEqual({ - status: 'COMPLETED', - results: [ - { - duplicateId: uuidDto.dummy, - status: 'FAILED', - reason: expect.stringContaining('not found or access denied'), - }, - ], - }); - }); - - it('should resolve duplicate group with keepers', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000002'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body).toEqual({ - status: 'COMPLETED', - results: [ - { - duplicateId, - status: 'SUCCESS', - }, - ], - }); - - // Verify side effects: duplicateId cleared on kept asset - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.duplicateId).toBeNull(); - - // Verify side effects: trashed asset is trashed and duplicateId cleared - const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id); - expect(trashedAsset.isTrashed).toBe(true); - expect(trashedAsset.duplicateId).toBeNull(); - }); - - it('should reject when keepAssetIds and trashAssetIds overlap', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000003'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('disjoint'); - }); - - it('should require keepAssetIds when partially trashing', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000004'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('must cover all assets'); - }); - - it('should reject partial resolution (not all assets covered)', async () => { - const [asset1, asset2, asset3] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000010'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('must cover all assets'); - }); - - it('should reject asset not in duplicate group', async () => { - const [asset1, asset2, outsideAsset] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000011'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('not a member of duplicate group'); - }); - - it('should allow trash-all without keepers', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000012'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }], - }); - - expect(status).toBe(200); - expect(body).toEqual({ - status: 'COMPLETED', - results: [ - { - duplicateId, - status: 'SUCCESS', - }, - ], - }); - - // Verify both assets are trashed - const [asset1Info, asset2Info] = await Promise.all([ - utils.getAssetInfo(user1.accessToken, asset1.id), - utils.getAssetInfo(user1.accessToken, asset2.id), - ]); - - expect(asset1Info.isTrashed).toBe(true); - expect(asset1Info.duplicateId).toBeNull(); - expect(asset2Info.isTrashed).toBe(true); - expect(asset2Info.duplicateId).toBeNull(); - }); - - it('should reject cross-user duplicate group access', async () => { - const asset1 = await utils.createAsset(user1.accessToken); - const asset2 = await utils.createAsset(user2.accessToken); - - const duplicateId = '00000000-0000-4000-8000-000000000013'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId); - - // User1 tries to resolve a group containing user2's asset - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('not a member of duplicate group'); - }); - - it('should synchronize favorites when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Mark one asset as favorite - await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [asset2.id], isFavorite: true }); - - const duplicateId = '00000000-0000-4000-8000-000000000020'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify favorite was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.isFavorite).toBe(true); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize visibility when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Archive one asset - await utils.archiveAssets(user1.accessToken, [asset2.id]); - - const duplicateId = '00000000-0000-4000-8000-000000000021'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify visibility was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.visibility).toBe('archive'); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize rating when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Set rating on one asset - await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [asset2.id], rating: 5 }); - - const duplicateId = '00000000-0000-4000-8000-000000000022'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify rating was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.exifInfo?.rating).toBe(5); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize description when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Set description on one asset - await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [asset2.id], description: 'Test description for duplicate' }); - - const duplicateId = '00000000-0000-4000-8000-000000000023'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify description was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate'); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize location when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Set location on one asset - await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 }); - - const duplicateId = '00000000-0000-4000-8000-000000000024'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify location was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.exifInfo?.latitude).toBe(40.7128); - expect(keptAsset.exifInfo?.longitude).toBe(-74.006); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize albums when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Create albums and add assets to different albums - const album1 = await utils.createAlbum(user1.accessToken, { - albumName: 'Album 1', - assetIds: [asset1.id], - }); - const album2 = await utils.createAlbum(user1.accessToken, { - albumName: 'Album 2', - assetIds: [asset2.id], - }); - - const duplicateId = '00000000-0000-4000-8000-000000000025'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify keeper is now in both albums - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.duplicateId).toBeNull(); - - // Check albums directly - const { status: album1Status, body: album1Body } = await request(app) - .get(`/albums/${album1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - const { status: album2Status, body: album2Body } = await request(app) - .get(`/albums/${album2.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(album1Status).toBe(200); - expect(album2Status).toBe(200); - expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id); - expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id); - }); - - it('should synchronize tags when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Wait for metadata extraction to complete before adding tags - // Otherwise, metadata jobs will race and overwrite our tags - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - - // Create tags and tag assets differently - const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']); - await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]); - await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]); - - const duplicateId = '00000000-0000-4000-8000-000000000026'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify keeper has both tags - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.duplicateId).toBeNull(); - expect(keptAsset.tags).toBeDefined(); - const tagIds = keptAsset.tags?.map((t) => t.id) || []; - expect(tagIds).toContain(tags[0].id); - expect(tagIds).toContain(tags[1].id); - }); - - it('should handle batch resolve with mixed success and failure', async () => { - // Create first group that will succeed - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - const duplicateId1 = '00000000-0000-4000-8000-000000000027'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1); - - // Create second group with non-existent duplicate ID (will fail) - const fakeId = '00000000-0000-4000-8000-000000000099'; - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [ - { duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }, - { duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] }, - ], - }); - - expect(status).toBe(200); - expect(body.status).toBe('COMPLETED'); - expect(body.results).toHaveLength(2); - - // First group should succeed - expect(body.results[0].duplicateId).toBe(duplicateId1); - expect(body.results[0].status).toBe('SUCCESS'); - - // Second group should fail - expect(body.results[1].duplicateId).toBe(fakeId); - expect(body.results[1].status).toBe('FAILED'); - expect(body.results[1].reason).toContain('not found or access denied'); - - // Verify first group was actually resolved despite second failure - const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(asset1Info.duplicateId).toBeNull(); - const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id); - expect(asset2Info.isTrashed).toBe(true); - }); - - it('should trash assets when trash is enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000028'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - // Ensure trash is enabled (default) - const config = await utils.getSystemConfig(admin.accessToken); - expect(config.trash.enabled).toBe(true); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify asset is trashed (not deleted) - const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id); - expect(trashedAsset.isTrashed).toBe(true); - }); - - it('should delete assets when trash is disabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000029'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - // Disable trash - await request(app) - .put('/system-config') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - trash: { enabled: false, days: 30 }, - }); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Asset should be marked as deleted (force delete) - const { status: getStatus } = await request(app) - .get(`/assets/${asset2.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - // Asset should still be accessible (soft deleted) but marked as deleted - expect(getStatus).toBe(200); - - // Re-enable trash for other tests - await utils.resetAdminConfig(admin.accessToken); - }); - }); -}); diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 1e03ad6d24..a43e2c3e69 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -1,9 +1,7 @@ export const uuidDto = { - invalid: 'invalid-uuid', // valid uuid v4 notFound: '00000000-0000-4000-a000-000000000000', dummy: '00000000-0000-4000-a000-000000000001', - dummy2: '00000000-0000-4000-a000-000000000002', }; const adminLoginDto = { @@ -57,22 +55,6 @@ export const createUserDto = { }; export const userDto = { - admin: { - name: signupDto.admin.name, - email: signupDto.admin.email, - password: signupDto.admin.password, - storageLabel: 'admin', - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - tags: [], - assets: [], - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }, user1: { name: createUserDto.user1.name, email: createUserDto.user1.email, diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 1b3447767e..337d101cb4 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -40,41 +40,6 @@ export const errorDto = { }, }; -export const signupResponseDto = { - admin: { - avatarColor: expect.any(String), - id: expect.any(String), - name: 'Immich Admin', - email: 'admin@immich.cloud', - storageLabel: 'admin', - profileImagePath: '', - // why? lol - shouldChangePassword: true, - isAdmin: true, - createdAt: expect.any(String), - updatedAt: expect.any(String), - deletedAt: null, - oauthId: '', - quotaUsageInBytes: 0, - quotaSizeInBytes: null, - status: 'active', - license: null, - profileChangedAt: expect.any(String), - }, -}; - -export const loginResponseDto = { - admin: { - accessToken: expect.any(String), - name: 'Immich Admin', - isAdmin: true, - isOnboarded: false, - profileImagePath: '', - shouldChangePassword: true, - userEmail: 'admin@immich.cloud', - userId: expect.any(String), - }, -}; export const deviceDto = { current: { id: expect.any(String), diff --git a/e2e/src/specs/server/api/partner.e2e-spec.ts b/e2e/src/specs/server/api/partner.e2e-spec.ts index 9047a97055..0eb7d6db3d 100644 --- a/e2e/src/specs/server/api/partner.e2e-spec.ts +++ b/e2e/src/specs/server/api/partner.e2e-spec.ts @@ -1,6 +1,5 @@ import { LoginResponseDto, createPartner } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -29,13 +28,6 @@ describe('/partners', () => { }); describe('GET /partners', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/partners'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should get all partners shared by user', async () => { const { status, body } = await request(app) .get('/partners') @@ -58,13 +50,6 @@ describe('/partners', () => { }); describe('POST /partners/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/partners/${user3.userId}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should share with new partner', async () => { const { status, body } = await request(app) .post(`/partners/${user3.userId}`) @@ -85,13 +70,6 @@ describe('/partners', () => { }); describe('PUT /partners/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/partners/${user2.userId}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should update partner', async () => { const { status, body } = await request(app) .put(`/partners/${user2.userId}`) @@ -104,13 +82,6 @@ describe('/partners', () => { }); describe('DELETE /partners/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/partners/${user3.userId}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should delete partner', async () => { const { status } = await request(app) .delete(`/partners/${user3.userId}`) diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index 53857a31fa..f0eed82f7a 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -22,12 +22,6 @@ describe('/server', () => { }); describe('GET /server/about', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server/about'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should return about information', async () => { const { status, body } = await request(app) .get('/server/about') @@ -56,12 +50,6 @@ describe('/server', () => { }); describe('GET /server/storage', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server/storage'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should return the disk information', async () => { const { status, body } = await request(app) .get('/server/storage') @@ -149,12 +137,6 @@ describe('/server', () => { }); describe('GET /server/statistics', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server/statistics'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should only work for admins', async () => { const { status, body } = await request(app) .get('/server/statistics') @@ -213,12 +195,6 @@ describe('/server', () => { }); describe('GET /server/license', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server/license'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should only work for admins', async () => { const { status, body } = await request(app) .get('/server/license') @@ -241,12 +217,6 @@ describe('/server', () => { }); describe('DELETE /server/license', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete('/server/license'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should only work for admins', async () => { const { status, body } = await request(app) .delete('/server/license') @@ -266,12 +236,6 @@ describe('/server', () => { }); describe('PUT /server/license', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put('/server/license'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should only work for admins', async () => { const { status, body } = await request(app) .put('/server/license') diff --git a/e2e/src/specs/server/api/session.e2e-spec.ts b/e2e/src/specs/server/api/session.e2e-spec.ts index 0b632f78ba..1766b732cd 100644 --- a/e2e/src/specs/server/api/session.e2e-spec.ts +++ b/e2e/src/specs/server/api/session.e2e-spec.ts @@ -15,12 +15,6 @@ describe('/sessions', () => { }); describe('GET /sessions', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/sessions'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should get a list of authorized devices', async () => { const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -29,12 +23,6 @@ describe('/sessions', () => { }); describe('DELETE /sessions', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/sessions`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should logout all devices (except the current one)', async () => { for (let i = 0; i < 5; i++) { await login({ loginCredentialDto: loginDto.admin }); diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index 8cdf2dc03c..af189f49ac 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -137,13 +137,6 @@ describe('/shared-links', () => { }); describe('GET /shared-links', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/shared-links'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should get all shared links created by user', async () => { const { status, body } = await request(app) .get('/shared-links') @@ -200,12 +193,6 @@ describe('/shared-links', () => { }); describe('GET /shared-links/me', () => { - it('should not require admin authentication', async () => { - const { status } = await request(app).get('/shared-links/me').set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(403); - }); - it('should get data for correct shared link', async () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key }); @@ -293,13 +280,6 @@ describe('/shared-links', () => { }); describe('GET /shared-links/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/shared-links/${linkWithAlbum.id}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should get shared link by id', async () => { const { status, body } = await request(app) .get(`/shared-links/${linkWithAlbum.id}`) @@ -326,26 +306,6 @@ describe('/shared-links', () => { }); describe('POST /shared-links', () => { - it('should require authentication', async () => { - const { status, body } = await request(app) - .post('/shared-links') - .send({ type: SharedLinkType.Album, albumId: uuidDto.notFound }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require a type and the correspondent asset/album id', async () => { - const { status, body } = await request(app) - .post('/shared-links') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]), - ); - }); - it('should require an asset/album id', async () => { const { status, body } = await request(app) .post('/shared-links') @@ -353,17 +313,17 @@ describe('/shared-links', () => { .send({ type: SharedLinkType.Album }); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' })); + expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }])); }); it('should require a valid asset id', async () => { const { status, body } = await request(app) .post('/shared-links') .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound }); + .send({ type: SharedLinkType.Individual, assetIds: [uuidDto.notFound] }); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' })); + expect(body).toEqual(expect.objectContaining({ message: 'Not found or no asset.share access' })); }); it('should create a shared link', async () => { @@ -383,15 +343,6 @@ describe('/shared-links', () => { }); describe('PATCH /shared-links/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app) - .patch(`/shared-links/${linkWithAlbum.id}`) - .send({ description: 'foo' }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should fail if invalid link', async () => { const { status, body } = await request(app) .patch(`/shared-links/${uuidDto.notFound}`) @@ -474,13 +425,6 @@ describe('/shared-links', () => { }); describe('DELETE /shared-links/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/shared-links/${linkWithAlbum.id}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should fail if invalid link', async () => { const { status, body } = await request(app) .delete(`/shared-links/${uuidDto.notFound}`) diff --git a/e2e/src/specs/server/api/stack.e2e-spec.ts b/e2e/src/specs/server/api/stack.e2e-spec.ts index 76bf514dc8..3b8f642334 100644 --- a/e2e/src/specs/server/api/stack.e2e-spec.ts +++ b/e2e/src/specs/server/api/stack.e2e-spec.ts @@ -1,5 +1,5 @@ import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk'; -import { createUserDto, uuidDto } from 'src/fixtures'; +import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; @@ -25,42 +25,6 @@ describe('/stacks', () => { }); describe('POST /stacks', () => { - it('should require authentication', async () => { - const { status, body } = await request(app) - .post('/stacks') - .send({ assetIds: [asset.id] }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require at least two assets', async () => { - const { status, body } = await request(app) - .post('/stacks') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ assetIds: [asset.id] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]), - ); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(app) - .post('/stacks') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ assetIds: [uuidDto.invalid, uuidDto.invalid] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['assetIds', 0], message: 'Invalid UUID' }, - { path: ['assetIds', 1], message: 'Invalid UUID' }, - ]), - ); - }); - it('should require access', async () => { const user2Asset = await utils.createAsset(user2.accessToken); const { status, body } = await request(app) diff --git a/e2e/src/specs/server/api/system-metadata.e2e-spec.ts b/e2e/src/specs/server/api/system-metadata.e2e-spec.ts index bd17bf2524..114ed2f995 100644 --- a/e2e/src/specs/server/api/system-metadata.e2e-spec.ts +++ b/e2e/src/specs/server/api/system-metadata.e2e-spec.ts @@ -16,12 +16,6 @@ describe('/server-info', () => { }); describe('POST /system-metadata/admin-onboarding', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/system-metadata/admin-onboarding').send({ isOnboarded: true }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should only work for admins', async () => { const { status, body } = await request(app) .post('/system-metadata/admin-onboarding') @@ -47,12 +41,6 @@ describe('/server-info', () => { }); describe('GET /system-metadata/reverse-geocoding-state', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/system-metadata/reverse-geocoding-state'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should only work for admins', async () => { const { status, body } = await request(app) .get('/system-metadata/reverse-geocoding-state') diff --git a/e2e/src/specs/server/api/tag.e2e-spec.ts b/e2e/src/specs/server/api/tag.e2e-spec.ts index d303a1e98d..98d66f6350 100644 --- a/e2e/src/specs/server/api/tag.e2e-spec.ts +++ b/e2e/src/specs/server/api/tag.e2e-spec.ts @@ -41,12 +41,6 @@ describe('/tags', () => { }); describe('POST /tags', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/tags').send({ name: 'TagA' }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization (api key)', async () => { const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); @@ -117,12 +111,6 @@ describe('/tags', () => { }); describe('GET /tags', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/tags'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization (api key)', async () => { const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); const { status, body } = await request(app).get('/tags').set('x-api-key', secret); @@ -169,12 +157,6 @@ describe('/tags', () => { }); describe('PUT /tags', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/tags`).send({ name: 'TagA/TagB' }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization (api key)', async () => { const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' }); @@ -213,12 +195,6 @@ describe('/tags', () => { }); describe('PUT /tags/assets', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/tags/assets`).send({ tagIds: [], assetIds: [] }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization (api key)', async () => { const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); const { status, body } = await request(app) @@ -279,12 +255,6 @@ describe('/tags', () => { }); describe('GET /tags/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/tags/${uuidDto.notFound}`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -304,14 +274,6 @@ describe('/tags', () => { expect(body).toEqual(errorDto.missingPermission('tag.read')); }); - it('should require a valid uuid', async () => { - const { status, body } = await request(app) - .get(`/tags/${uuidDto.invalid}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); - }); - it('should get tag details', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -349,13 +311,6 @@ describe('/tags', () => { }); describe('PUT /tags/:id', () => { - it('should require authentication', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app).put(`/tags/${tag.id}`).send({ color: '#000000' }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const tag = await create(admin.accessToken, { name: 'tagA' }); const { status, body } = await request(app) @@ -399,12 +354,6 @@ describe('/tags', () => { }); describe('DELETE /tags/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/tags/${uuidDto.notFound}`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -422,14 +371,6 @@ describe('/tags', () => { expect(body).toEqual(errorDto.missingPermission('tag.delete')); }); - it('should require a valid uuid', async () => { - const { status, body } = await request(app) - .delete(`/tags/${uuidDto.invalid}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); - }); - it('should delete a tag', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status } = await request(app) @@ -463,15 +404,6 @@ describe('/tags', () => { }); describe('PUT /tags/:id/assets', () => { - it('should require authentication', async () => { - const tagA = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .put(`/tags/${tagA.id}/assets`) - .send({ ids: [userAsset.id] }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -531,16 +463,6 @@ describe('/tags', () => { }); describe('DELETE /tags/:id/assets', () => { - it('should require authentication', async () => { - const tagA = await create(admin.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .delete(`/tags/${tagA}/assets`) - .send({ ids: [userAsset.id] }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const tagA = await create(user.accessToken, { name: 'TagA' }); await tagAssets( diff --git a/e2e/src/specs/server/api/user-admin.e2e-spec.ts b/e2e/src/specs/server/api/user-admin.e2e-spec.ts index f442d4249d..160c929591 100644 --- a/e2e/src/specs/server/api/user-admin.e2e-spec.ts +++ b/e2e/src/specs/server/api/user-admin.e2e-spec.ts @@ -46,12 +46,6 @@ describe('/admin/users', () => { }); describe('GET /admin/users', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/admin/users`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const { status, body } = await request(app) .get(`/admin/users`) @@ -94,12 +88,6 @@ describe('/admin/users', () => { }); describe('POST /admin/users', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/admin/users`).send(createUserDto.user1); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const { status, body } = await request(app) .post(`/admin/users`) @@ -109,23 +97,6 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const [key, message] of [ - ['password', 'Invalid input: expected string, received null'], - ['email', 'Invalid input: expected email, received object'], - ['name', 'Invalid input: expected string, received null'], - ['shouldChangePassword', 'Invalid input: expected boolean, received null'], - ['notify', 'Invalid input: expected boolean, received null'], - ] as const) { - it(`should not allow null ${key}`, async () => { - const { status, body } = await request(app) - .post(`/admin/users`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ...createUserDto.user1, [key]: null }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); - }); - } - it('should accept `isAdmin`', async () => { const { status, body } = await request(app) .post(`/admin/users`) @@ -146,12 +117,6 @@ describe('/admin/users', () => { }); describe('PUT /admin/users/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/admin/users/${uuidDto.notFound}`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const { status, body } = await request(app) .put(`/admin/users/${uuidDto.notFound}`) @@ -160,22 +125,6 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const [key, message] of [ - ['password', 'Invalid input: expected string, received null'], - ['email', 'Invalid input: expected email, received object'], - ['name', 'Invalid input: expected string, received null'], - ['shouldChangePassword', 'Invalid input: expected boolean, received null'], - ] as const) { - it(`should not allow null ${key}`, async () => { - const { status, body } = await request(app) - .put(`/admin/users/${uuidDto.notFound}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ [key]: null }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); - }); - } - it('should allow a non-admin to become an admin', async () => { const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2')); const { status, body } = await request(app) @@ -245,12 +194,6 @@ describe('/admin/users', () => { }); describe('PUT /admin/users/:id/preferences', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/admin/users/${userToDelete.userId}/preferences`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should update memories enabled', async () => { const before = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toMatchObject({ memories: { enabled: true } }); @@ -282,12 +225,6 @@ describe('/admin/users', () => { }); describe('DELETE /admin/users/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const { status, body } = await request(app) .delete(`/admin/users/${userToDelete.userId}`) @@ -360,12 +297,6 @@ describe('/admin/users', () => { }); describe('GET /admin/users/:id/calendar-heatmap', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require admin permissions', async () => { const { status, body } = await request(app) .get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`) @@ -385,12 +316,6 @@ describe('/admin/users', () => { }); describe('POST /admin/users/:id/restore', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/admin/users/${userToDelete.userId}/restore`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should require authorization', async () => { const { status, body } = await request(app) .post(`/admin/users/${userToDelete.userId}/restore`) diff --git a/e2e/src/specs/server/api/user.e2e-spec.ts b/e2e/src/specs/server/api/user.e2e-spec.ts index 2dc789a91b..0e62672f36 100644 --- a/e2e/src/specs/server/api/user.e2e-spec.ts +++ b/e2e/src/specs/server/api/user.e2e-spec.ts @@ -1,34 +1,18 @@ -import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyPreferences, getMyUser, login } from '@immich/sdk'; +import { LoginResponseDto, SharedLinkType, getMyUser, login } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; -const userLicense = { - licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4', - activationKey: - 'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw', -}; - describe('/users', () => { let admin: LoginResponseDto; - let deletedUser: LoginResponseDto; let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - - [deletedUser, nonAdmin] = await Promise.all([ - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - ]); - - await deleteUserAdmin( - { id: deletedUser.userId, userAdminDeleteDto: {} }, - { headers: asBearerAuth(admin.accessToken) }, - ); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2); }); describe('GET /users/me', () => { @@ -42,55 +26,9 @@ describe('/users', () => { expect(status).toBe(403); expect(body).toEqual(errorDto.forbidden); }); - - it('should get my user', async () => { - const { status, body } = await request(app).get(`/users/me`).set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ - id: admin.userId, - email: 'admin@immich.cloud', - quotaUsageInBytes: 0, - }); - }); - - it('should get my user with license info', async () => { - const { status: licenseStatus } = await request(app) - .put(`/users/me/license`) - .send(userLicense) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(licenseStatus).toBe(200); - const { status, body } = await request(app) - .get(`/users/me`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ - id: nonAdmin.userId, - email: nonAdmin.userEmail, - quotaUsageInBytes: 0, - license: userLicense, - }); - }); }); describe('PUT /users/me', () => { - it('should update first and last name', async () => { - const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); - - const { status, body } = await request(app) - .put(`/users/me`) - .send({ name: 'Name' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ - ...before, - updatedAt: expect.any(String), - profileChangedAt: expect.any(String), - createdAt: expect.any(String), - name: 'Name', - }); - }); - /** @deprecated */ it('should allow a user to change their password (deprecated)', async () => { const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); @@ -112,222 +50,5 @@ describe('/users', () => { expect(token.accessToken).toBeDefined(); }); - - it('should not allow user to change to a taken email', async () => { - const { status, body } = await request(app) - .put(`/users/me`) - .send({ email: 'admin@immich.cloud' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - - expect(status).toBe(400); - expect(body).toMatchObject(errorDto.badRequest('Email is not available')); - }); - - it('should update my email', async () => { - const before = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); - const { status, body } = await request(app) - .put(`/users/me`) - .send({ email: 'non-admin@immich.cloud' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ - ...before, - email: 'non-admin@immich.cloud', - updatedAt: expect.anything(), - createdAt: expect.anything(), - profileChangedAt: expect.anything(), - }); - }); - - it('should update avatar color', async () => { - const { status, body } = await request(app) - .put(`/users/me`) - .send({ avatarColor: 'blue' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ avatarColor: 'blue' }); - - const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ avatarColor: 'blue' }); - }); - }); - - describe('PUT /users/me/preferences', () => { - it('should update memories enabled', async () => { - const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(before).toMatchObject({ memories: { enabled: true } }); - - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ memories: { enabled: false } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ memories: { enabled: false } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ memories: { enabled: false } }); - }); - - it('should require an integer for download archive size', async () => { - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ download: { archiveSize: 1_234_567.89 } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' }, - ]), - ); - }); - - it('should update download archive size', async () => { - const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(before).toMatchObject({ download: { archiveSize: 4 * 2 ** 30 } }); - - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ download: { archiveSize: 1_234_567 } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ download: { archiveSize: 1_234_567 } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); - }); - - it('should require a boolean for download include embedded videos', async () => { - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ download: { includeEmbeddedVideos: 1_234_567.89 } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' }, - ]), - ); - }); - - it('should update download include embedded videos', async () => { - const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(before).toMatchObject({ download: { includeEmbeddedVideos: false } }); - - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ download: { includeEmbeddedVideos: true } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ download: { includeEmbeddedVideos: true } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } }); - }); - - it('should update minimum face count to display people', async () => { - const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(before).toMatchObject({ people: { minimumFaces: 3 } }); - - const { status, body } = await request(app) - .put('/users/me/preferences') - .send({ people: { minimumFaces: 2 } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ people: { minimumFaces: 2 } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ people: { minimumFaces: 2 } }); - }); - }); - - describe('GET /users/:id', () => { - it('should get the user', async () => { - const { status, body } = await request(app) - .get(`/users/${admin.userId}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ - id: admin.userId, - email: 'admin@immich.cloud', - }); - - expect(body).not.toMatchObject({ - shouldChangePassword: expect.anything(), - storageLabel: expect.anything(), - }); - }); - }); - - describe('GET /server/license', () => { - it('should return the user license', async () => { - await request(app) - .put('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send(userLicense); - const { status, body } = await request(app) - .get('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ - ...userLicense, - activatedAt: expect.any(String), - }); - }); - }); - - describe('PUT /users/me/license', () => { - it('should set the user license', async () => { - const { status, body } = await request(app) - .put(`/users/me/license`) - .send(userLicense) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ ...userLicense, activatedAt: expect.any(String) }); - expect(status).toBe(200); - expect(body).toEqual({ ...userLicense, activatedAt: expect.any(String) }); - const { body: licenseBody } = await request(app) - .get('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(licenseBody).toEqual({ ...userLicense, activatedAt: expect.any(String) }); - }); - - it('should reject license not starting with IMCL-', async () => { - const { status, body } = await request(app) - .put('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ licenseKey: 'IMSV-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', activationKey: 'activationKey' }); - expect(status).toBe(400); - expect(body.message).toBe('Invalid license key'); - }); - - it('should reject license with invalid activation key', async () => { - const { status, body } = await request(app) - .put('/users/me/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ licenseKey: userLicense.licenseKey, activationKey: `invalid${userLicense.activationKey}` }); - expect(status).toBe(400); - expect(body.message).toBe('Invalid license key'); - }); - }); - - describe('DELETE /users/me/license', () => { - it('should require authentication', async () => { - const { status } = await request(app).put(`/users/me/license`); - expect(status).toEqual(401); - }); - - it('should delete the user license', async () => { - const { status } = await request(app) - .delete(`/users/me/license`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(204); - }); }); }); diff --git a/e2e/src/ui/generators/timeline/timeline-config.ts b/e2e/src/ui/generators/timeline/timeline-config.ts index a3b59bb8c8..5bfb2c1800 100644 --- a/e2e/src/ui/generators/timeline/timeline-config.ts +++ b/e2e/src/ui/generators/timeline/timeline-config.ts @@ -24,13 +24,6 @@ export const ASPECT_RATIO_WEIGHTS = { '3:1': 0.01, // 1% 3:1 panorama } as const; -export type AspectRatio = { - width: number; - height: number; - ratio: number; - name: string; -}; - // Mock configuration for asset generation - will be transformed to API response formats export type MockTimelineAsset = { id: string; diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index dad60266b9..986f794fb6 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -1,4 +1,4 @@ -import { BrowserContext, expect, Page } from '@playwright/test'; +import { expect, Page } from '@playwright/test'; import { DateTime } from 'luxon'; import { TimelineAssetConfig } from 'src/ui/generators/timeline'; @@ -11,18 +11,6 @@ export const padYearMonth = (yearMonth: string) => { return `${year}-${month.padStart(2, '0')}`; }; -export async function throttlePage(context: BrowserContext, page: Page) { - const session = await context.newCDPSession(page); - await session.send('Network.emulateNetworkConditions', { - offline: false, - downloadThroughput: (1.5 * 1024 * 1024) / 8, - uploadThroughput: (750 * 1024) / 8, - latency: 40, - connectionType: 'cellular3g', - }); - await session.send('Emulation.setCPUThrottlingRate', { rate: 10 }); -} - export const poll = async ( page: Page, query: () => Promise, diff --git a/i18n/en.json b/i18n/en.json index 857265f004..0acfe68fa3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1788,6 +1788,7 @@ "restore_all": "Restore all", "restore_user": "Restore user", "restored_asset": "Restored asset", + "result": "Result", "resume": "Resume", "resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}", "review_duplicates": "Review duplicates", @@ -2139,6 +2140,8 @@ "trigger": "Trigger", "trigger_asset_metadata_extraction": "Asset Metadata Extraction", "trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted", + "trigger_asset_tagged": "Asset Tagged", + "trigger_asset_tagged_description": "Triggered when a tag is added to an asset", "trigger_asset_uploaded": "Asset Upload", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_person_recognized": "Person Recognized", @@ -2186,7 +2189,7 @@ "upload_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}", "upload_details": "Upload Details", "upload_error_with_count": "Upload error for {count, plural, one {# asset} other {# assets}}", - "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.", + "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}.", "upload_finished": "Upload finished", "upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}", "upload_skipped_duplicates": "Skipped {count, plural, one {# duplicate asset} other {# duplicate assets}}", @@ -2276,6 +2279,13 @@ "workflow_info": "Workflow info", "workflow_json": "Workflow JSON", "workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.", + "workflow_logging_completed": "Completed", + "workflow_logging_disable": "Disable logging", + "workflow_logging_disabled_description": "Logging is currently disabled for this workflow.", + "workflow_logging_enable": "Enable logging", + "workflow_logging_error_step": "Error on step #{step}", + "workflow_logging_halted": "Halted", + "workflow_logging_halted_step": "Halted on step #{step}", "workflow_name": "Workflow name", "workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?", "workflow_summary": "Workflow summary", diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index afbd72e99e..c8191e307b 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -160,8 +160,8 @@ ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF} ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT} ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT} -# store matplotlib config in /cache subfolder to enable rootless deployment with a single volume -ENV MPLCONFIGDIR=/cache/matplotlib +# store huggingface in /cache subfolder to enable rootless deployment with a single volume +ENV HF_HOME=/cache/hf-cache ENTRYPOINT ["tini", "--"] CMD ["python", "-m", "immich_ml"] diff --git a/machine-learning/conftest.py b/machine-learning/conftest.py index 57741f3807..a5acc059d1 100644 --- a/machine-learning/conftest.py +++ b/machine-learning/conftest.py @@ -1,11 +1,10 @@ import json -from typing import Any, Iterator +from types import SimpleNamespace +from typing import Any, Callable, Iterator from unittest import mock -import numpy as np import pytest from fastapi.testclient import TestClient -from numpy.typing import NDArray from PIL import Image from immich_ml.config import log @@ -17,11 +16,6 @@ def pil_image() -> Image.Image: return Image.new("RGB", (600, 800)) -@pytest.fixture -def cv_image(pil_image: Image.Image) -> NDArray[np.float32]: - return np.asarray(pil_image)[:, :, ::-1] # PIL uses RGB while cv2 uses BGR - - @pytest.fixture def mock_get_model() -> Iterator[mock.Mock]: with mock.patch("immich_ml.models.cache.from_model_type", autospec=True) as mocked: @@ -129,6 +123,18 @@ def ort_session() -> Iterator[mock.Mock]: yield mocked +@pytest.fixture(scope="function") +def stub_session() -> Callable[..., mock.Mock]: + def _make(shape: tuple[Any, ...], outputs: Any = None, name: str = "input.1") -> mock.Mock: + session = mock.Mock() + session.get_inputs.return_value = [SimpleNamespace(name=name, shape=shape)] + if outputs is not None: + session.run.return_value = outputs + return session + + return _make + + @pytest.fixture(scope="function") def ann_session() -> Iterator[mock.Mock]: with mock.patch("immich_ml.sessions.ann.Ann") as mocked: diff --git a/machine-learning/immich_ml/models/base.py b/machine-learning/immich_ml/models/base.py index 00790ce744..5c40b21f32 100644 --- a/machine-learning/immich_ml/models/base.py +++ b/machine-learning/immich_ml/models/base.py @@ -57,7 +57,7 @@ class InferenceModel(ABC): self.load() if model_kwargs: self.configure(**model_kwargs) - return self._predict(*inputs) + return self._predict(*inputs, **model_kwargs) @abstractmethod def _predict(self, *inputs: Any, **model_kwargs: Any) -> Any: ... diff --git a/machine-learning/immich_ml/models/facial_recognition/_ops.py b/machine-learning/immich_ml/models/facial_recognition/_ops.py new file mode 100644 index 0000000000..6bdf5f5c3c --- /dev/null +++ b/machine-learning/immich_ml/models/facial_recognition/_ops.py @@ -0,0 +1,74 @@ +""" +Host-side geometry for the fused face models. + +The decode, NMS and Umeyama similarity are ports of insightface (Apache-2.0, https://github.com/deepinsight/insightface) +""" + +from functools import lru_cache + +import cv2 +import numpy as np +from numpy.typing import NDArray + +from immich_ml.models.transforms import ensure_dims + +DET_SIZE = 640 +ALIGNED_SIZE = 112 + +# the FPN levels for which the fused detector emits a (scores, boxes, kps) triple, +# and the anchors each feature-map cell carries, laid out anchor-major +DET_STRIDES = (8, 16, 32) +ANCHORS_PER_CELL = 2 + +# canonical ArcFace 5-point template for a 112x112 crop +ARCFACE_DST = np.array( + [[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]], + dtype=np.float32, +) + + +@lru_cache(maxsize=len(DET_STRIDES)) +def _anchor_centers(size: int, stride: int) -> NDArray[np.float32]: + ys, xs = np.mgrid[: size // stride, : size // stride] + centers = np.stack([xs, ys], axis=-1, dtype=np.float32).reshape(-1, 2) * stride + return np.repeat(centers, ANCHORS_PER_CELL, axis=0) + + +def decode_scrfd( + heads: list[NDArray[np.float32]], size: int = DET_SIZE +) -> tuple[NDArray[np.float32], NDArray[np.float32], NDArray[np.float32]]: + heads = [ensure_dims(head, 4) for head in heads] + scores, boxes, kps = [], [], [] + for level, stride in enumerate(DET_STRIDES): + centers = _anchor_centers(size, stride) + distance = heads[level + len(DET_STRIDES)][0] * stride + offsets = heads[level + 2 * len(DET_STRIDES)][0] * stride + scores.append(heads[level][0].squeeze(-1)) + boxes.append(np.concatenate([centers[None] - distance[:, :, :2], centers[None] + distance[:, :, 2:]], axis=2)) + kps.append(np.tile(centers, offsets.shape[2] // 2) + offsets) + + return np.concatenate(scores, axis=1), np.concatenate(boxes, axis=1), np.concatenate(kps, axis=1) + + +def nms(boxes: NDArray[np.float32], scores: NDArray[np.float32], threshold: float = 0.4) -> NDArray[np.intp]: + wh = np.column_stack([boxes[:, 0], boxes[:, 1], boxes[:, 2] - boxes[:, 0], boxes[:, 3] - boxes[:, 1]]) + keep = cv2.dnn.NMSBoxes(wh.tolist(), scores.tolist(), 0.0, threshold) # NMSBoxes treats the inputs as Sequences + return np.asarray(keep, dtype=np.intp).reshape(-1) + + +def umeyama(src: NDArray[np.float32], dst: NDArray[np.float32]) -> NDArray[np.float32]: + src_mean, dst_mean = src.mean(0), dst.mean(0) + src_c, dst_c = src - src_mean, dst - dst_mean + cov = dst_c.T @ src_c / len(src) + u, s, vt = np.linalg.svd(cov) + d = np.sign(np.linalg.det(u @ vt)) + diag = np.diag([1.0, d]) + rotation = u @ diag @ vt + scale = np.trace(np.diag(s) @ diag) / (src_c**2).sum() * len(src) + translation = dst_mean - scale * rotation @ src_mean + return np.hstack([scale * rotation, translation[:, None]], dtype=np.float32) + + +def align_face(image: NDArray[np.uint8], kps: NDArray[np.float32]) -> NDArray[np.float32]: + matrix = umeyama(kps, ARCFACE_DST) + return cv2.warpAffine(image, matrix, (ALIGNED_SIZE, ALIGNED_SIZE)).astype(np.float32) diff --git a/machine-learning/immich_ml/models/facial_recognition/detection.py b/machine-learning/immich_ml/models/facial_recognition/detection.py index 26c9208e48..a113755201 100644 --- a/machine-learning/immich_ml/models/facial_recognition/detection.py +++ b/machine-learning/immich_ml/models/facial_recognition/detection.py @@ -1,41 +1,31 @@ -from typing import Any - import numpy as np -from insightface.model_zoo import RetinaFace from numpy.typing import NDArray from immich_ml.models.base import InferenceModel -from immich_ml.models.transforms import decode_cv2 -from immich_ml.schemas import FaceDetectionOutput, ModelSession, ModelTask, ModelType +from immich_ml.models.transforms import decode_pil, letterbox, normalize +from immich_ml.schemas import FaceDetectionOutput, ModelTask, ModelType + +from ._ops import DET_SIZE, decode_scrfd, nms class FaceDetector(InferenceModel): depends = [] identity = (ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION) - def __init__(self, model_name: str, min_score: float = 0.7, **model_kwargs: Any) -> None: - self.min_score = model_kwargs.pop("minScore", min_score) - super().__init__(model_name, **model_kwargs) + def _predict(self, inputs: NDArray[np.uint8] | bytes, minScore: float) -> FaceDetectionOutput: + canvas, scale = letterbox(decode_pil(inputs), DET_SIZE) + blob = normalize(canvas.astype(np.float32), mean=127.5, std=128).transpose(2, 0, 1)[None] - def _load(self) -> ModelSession: - session = self._make_session(self.model_path) - self.model = RetinaFace(session=session) - self.model.prepare(ctx_id=0, det_thresh=self.min_score, input_size=(640, 640)) + input_name = self.session.get_inputs()[0].name + heads = self.session.run(None, {input_name: blob}) + scores, boxes, kps = decode_scrfd(heads, DET_SIZE) - return session + candidates = scores >= minScore + scores, boxes, kps = scores[candidates], boxes[candidates] / scale, kps[candidates] / scale + keep = nms(boxes, scores) - def _predict(self, inputs: NDArray[np.uint8] | bytes) -> FaceDetectionOutput: - inputs = decode_cv2(inputs) - - bboxes, landmarks = self._detect(inputs) return { - "boxes": bboxes[:, :4].round(), - "scores": bboxes[:, 4], - "landmarks": landmarks, + "boxes": boxes[keep].round(), + "scores": scores[keep], + "landmarks": kps[keep].reshape(-1, 5, 2), } - - def _detect(self, inputs: NDArray[np.uint8] | bytes) -> tuple[NDArray[np.float32], NDArray[np.float32]]: - return self.model.detect(inputs) # type: ignore - - def configure(self, **kwargs: Any) -> None: - self.model.det_thresh = kwargs.pop("minScore", self.model.det_thresh) diff --git a/machine-learning/immich_ml/models/facial_recognition/recognition.py b/machine-learning/immich_ml/models/facial_recognition/recognition.py index c144bd3eaa..2b0575ec38 100644 --- a/machine-learning/immich_ml/models/facial_recognition/recognition.py +++ b/machine-learning/immich_ml/models/facial_recognition/recognition.py @@ -4,15 +4,13 @@ from typing import Any import numpy as np import onnx import onnxruntime as ort -from insightface.model_zoo import ArcFaceONNX -from insightface.utils.face_align import norm_crop from numpy.typing import NDArray from onnx.tools.update_model_dims import update_inputs_outputs_dims from PIL import Image from immich_ml.config import log, settings from immich_ml.models.base import InferenceModel -from immich_ml.models.transforms import decode_cv2, serialize_np_array +from immich_ml.models.transforms import decode_pil, normalize, serialize_np_array from immich_ml.schemas import ( FaceDetectionOutput, FacialRecognitionOutput, @@ -22,6 +20,8 @@ from immich_ml.schemas import ( ModelType, ) +from ._ops import align_face + class FaceRecognizer(InferenceModel): depends = [(ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)] @@ -37,10 +37,6 @@ class FaceRecognizer(InferenceModel): if (not self.batch_size or self.batch_size > 1) and str(session.get_inputs()[0].shape[0]) != "batch": self._add_batch_axis(self.model_path) session = self._make_session(self.model_path) - self.model = ArcFaceONNX( - self.model_path_for_format(ModelFormat.ONNX).as_posix(), - session=session, - ) return session def _predict( @@ -48,20 +44,21 @@ class FaceRecognizer(InferenceModel): ) -> FacialRecognitionOutput: if faces["boxes"].shape[0] == 0: return [] - inputs = decode_cv2(inputs) - cropped_faces = self._crop(inputs, faces) - embeddings = self._predict_batch(cropped_faces) + image = np.asarray(decode_pil(inputs), dtype=np.uint8) + crops = np.stack([align_face(image, kps).transpose(2, 0, 1) for kps in faces["landmarks"]]) + embeddings = self._predict_batch(normalize(crops, mean=127.5, std=127.5)) return self.postprocess(faces, embeddings) - def _predict_batch(self, cropped_faces: list[NDArray[np.uint8]]) -> NDArray[np.float32]: - if not self.batch_size or len(cropped_faces) <= self.batch_size: - embeddings: NDArray[np.float32] = self.model.get_feat(cropped_faces) - return embeddings + def _predict_batch(self, crops: NDArray[np.float32]) -> NDArray[np.float32]: + input_name = self.session.get_inputs()[0].name + if not self.batch_size or crops.shape[0] <= self.batch_size: + return self.session.run(None, {input_name: crops})[0] - batch_embeddings: list[NDArray[np.float32]] = [] - for i in range(0, len(cropped_faces), self.batch_size): - batch_embeddings.append(self.model.get_feat(cropped_faces[i : i + self.batch_size])) - return np.concatenate(batch_embeddings, axis=0) + batches = [ + self.session.run(None, {input_name: crops[i : i + self.batch_size]})[0] + for i in range(0, crops.shape[0], self.batch_size) + ] + return np.concatenate(batches, axis=0) def postprocess(self, faces: FaceDetectionOutput, embeddings: NDArray[np.float32]) -> FacialRecognitionOutput: return [ @@ -73,9 +70,6 @@ class FaceRecognizer(InferenceModel): for (x1, y1, x2, y2), embedding, score in zip(faces["boxes"], embeddings, faces["scores"]) ] - def _crop(self, image: NDArray[np.uint8], faces: FaceDetectionOutput) -> list[NDArray[np.uint8]]: - return [norm_crop(image, landmark) for landmark in faces["landmarks"]] - def _add_batch_axis(self, model_path: Path) -> None: log.debug(f"Adding batch axis to model {model_path}") proto = onnx.load(model_path) diff --git a/machine-learning/immich_ml/models/ocr/detection.py b/machine-learning/immich_ml/models/ocr/detection.py index 0a2cb8ad91..48dd87854b 100644 --- a/machine-learning/immich_ml/models/ocr/detection.py +++ b/machine-learning/immich_ml/models/ocr/detection.py @@ -63,7 +63,8 @@ class TextDetector(InferenceModel): return OrtSession(self.model_path) # partly adapted from RapidOCR - def _predict(self, inputs: Image.Image) -> TextDetectionOutput: + # TODO: take the options as parameters instead of applying them through `configure` + def _predict(self, inputs: Image.Image, **kwargs: Any) -> TextDetectionOutput: w, h = inputs.size if w < 32 or h < 32: return self._empty diff --git a/machine-learning/immich_ml/models/ocr/recognition.py b/machine-learning/immich_ml/models/ocr/recognition.py index 94f40c9285..ea7ff565d1 100644 --- a/machine-learning/immich_ml/models/ocr/recognition.py +++ b/machine-learning/immich_ml/models/ocr/recognition.py @@ -69,7 +69,8 @@ class TextRecognizer(InferenceModel): ) return session - def _predict(self, img: Image.Image, texts: TextDetectionOutput) -> TextRecognitionOutput: + # TODO: take the options as parameters instead of applying them through `configure` + def _predict(self, img: Image.Image, texts: TextDetectionOutput, **kwargs: Any) -> TextRecognitionOutput: boxes, box_scores = texts["boxes"], texts["scores"] if boxes.shape[0] == 0: return self._empty diff --git a/machine-learning/immich_ml/models/transforms.py b/machine-learning/immich_ml/models/transforms.py index 3f9d93bed3..ac220a3812 100644 --- a/machine-learning/immich_ml/models/transforms.py +++ b/machine-learning/immich_ml/models/transforms.py @@ -1,6 +1,5 @@ import string from io import BytesIO -from typing import IO import cv2 import numpy as np @@ -12,11 +11,10 @@ _PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in _PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation) -def resize_pil(img: Image.Image, size: int) -> Image.Image: +def resize_pil(img: Image.Image, size: int, resample: Image.Resampling = Image.Resampling.BICUBIC) -> Image.Image: if img.width < img.height: - return img.resize((size, int((img.height / img.width) * size)), resample=Image.Resampling.BICUBIC) - else: - return img.resize((int((img.width / img.height) * size), size), resample=Image.Resampling.BICUBIC) + return img.resize((size, int((img.height / img.width) * size)), resample=resample) + return img.resize((int((img.width / img.height) * size), size), resample=resample) # https://stackoverflow.com/a/60883103 @@ -36,7 +34,9 @@ def to_numpy(img: Image.Image) -> NDArray[np.float32]: def normalize( img: NDArray[np.float32], mean: float | NDArray[np.float32], std: float | NDArray[np.float32] ) -> NDArray[np.float32]: - return (img - mean) / std + img *= 1.0 / std + img -= mean / std + return img def get_pil_resampling(resample: str) -> Image.Resampling: @@ -47,26 +47,21 @@ def pil_to_cv2(image: Image.Image) -> NDArray[np.uint8]: return cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR) # type: ignore -def decode_pil(image_bytes: bytes | IO[bytes] | Image.Image) -> Image.Image: - if isinstance(image_bytes, Image.Image): - return image_bytes - image: Image.Image = Image.open(BytesIO(image_bytes) if isinstance(image_bytes, bytes) else image_bytes) +def decode_pil(image_bytes: bytes | Image.Image | NDArray[np.uint8]) -> Image.Image: + image: Image.Image + match image_bytes: + case Image.Image(): + image = image_bytes + case np.ndarray(): + image = Image.fromarray(image_bytes) + case bytes(): + image = Image.open(BytesIO(image_bytes)) image.load() if not image.mode == "RGB": image = image.convert("RGB") return image -def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[np.uint8]: - match image_bytes: - case bytes() | memoryview() | bytearray(): - return pil_to_cv2(decode_pil(image_bytes)) # pillow is much faster than cv2 - case Image.Image(): - return pil_to_cv2(image_bytes) - case _: - return image_bytes - - def clean_text(text: str, canonicalize: bool = False) -> str: text = " ".join(text.split()) if canonicalize: @@ -78,3 +73,21 @@ def clean_text(text: str, canonicalize: bool = False) -> str: # TODO: use this in a less invasive way def serialize_np_array(arr: NDArray[np.float32]) -> str: return orjson.dumps(arr, option=orjson.OPT_SERIALIZE_NUMPY).decode() + + +def letterbox(image: NDArray[np.uint8] | Image.Image, size: int) -> tuple[NDArray[np.uint8], float]: + if isinstance(image, Image.Image): + image = np.asarray(image) + height, width = image.shape[:2] + if height > width: + new_height, new_width = size, int(size * width / height) + else: + new_width, new_height = size, int(size * height / width) + + canvas = np.zeros((size, size, 3), dtype=np.uint8) + cv2.resize(image, (new_width, new_height), dst=canvas[:new_height, :new_width]) + return canvas, new_height / height + + +def ensure_dims(array: NDArray[np.float32], ndim: int) -> NDArray[np.float32]: + return array if array.ndim >= ndim else np.expand_dims(array, axis=tuple(range(ndim - array.ndim))) diff --git a/machine-learning/immich_ml/schemas.py b/machine-learning/immich_ml/schemas.py index d5304cf763..d5695f5eb3 100644 --- a/machine-learning/immich_ml/schemas.py +++ b/machine-learning/immich_ml/schemas.py @@ -14,10 +14,7 @@ class ORJSONResponse(JSONResponse): class StrEnum(str, Enum): - value: str - - def __str__(self) -> str: - return self.value + __str__ = str.__str__ class BoundingBox(TypedDict): @@ -63,7 +60,7 @@ ModelIdentity = tuple[ModelType, ModelTask] class SessionNode(Protocol): @property - def name(self) -> str | None: ... + def name(self) -> str: ... @property def shape(self) -> tuple[int, ...]: ... diff --git a/machine-learning/immich_ml/sessions/ann/__init__.py b/machine-learning/immich_ml/sessions/ann/__init__.py index 6f36f675f6..d3279f9a17 100644 --- a/machine-learning/immich_ml/sessions/ann/__init__.py +++ b/machine-learning/immich_ml/sessions/ann/__init__.py @@ -37,11 +37,11 @@ class AnnSession: def get_inputs(self) -> list[SessionNode]: shapes = self.ann.input_shapes[self.model] - return [AnnNode(None, s) for s in shapes] + return [AnnNode(f"input.{i + 1}", s) for i, s in enumerate(shapes)] def get_outputs(self) -> list[SessionNode]: shapes = self.ann.output_shapes[self.model] - return [AnnNode(None, s) for s in shapes] + return [AnnNode(f"output.{i + 1}", s) for i, s in enumerate(shapes)] def run( self, @@ -54,5 +54,5 @@ class AnnSession: class AnnNode(NamedTuple): - name: str | None + name: str shape: tuple[int, ...] diff --git a/machine-learning/immich_ml/sessions/rknn/__init__.py b/machine-learning/immich_ml/sessions/rknn/__init__.py index e388e4febc..90f2e63ed3 100644 --- a/machine-learning/immich_ml/sessions/rknn/__init__.py +++ b/machine-learning/immich_ml/sessions/rknn/__init__.py @@ -69,7 +69,7 @@ class RknnSession: class RknnNode(NamedTuple): - name: str | None + name: str shape: tuple[int, ...] diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index af4d8edf44..e95ef7afef 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -10,8 +10,8 @@ dependencies = [ "fastapi>=0.95.2,<1.0", "gunicorn>=21.1.0", "huggingface-hub>=1.0,<2.0", - "insightface>=0.7.3,<2.0", "numpy>=2.4.0,<3.0", + "onnx>=1.22.0", "opencv-python-headless>=4.7.0.72,<5.0", "orjson>=3.9.5", "pillow>=12.2,<13", diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py index be574c6397..567c6ab176 100644 --- a/machine-learning/test_main.py +++ b/machine-learning/test_main.py @@ -7,7 +7,6 @@ from types import SimpleNamespace from typing import Any, Callable from unittest import mock -import cv2 import numpy as np import onnxruntime as ort import orjson @@ -516,7 +515,7 @@ class TestAnnSession: inputs = session.get_inputs() assert len(inputs) == 1 - assert inputs[0].name is None + assert inputs[0].name == "input.1" assert inputs[0].shape == (1, 3, 224, 224) def test_get_outputs(self, ann_session: mock.Mock) -> None: @@ -527,7 +526,7 @@ class TestAnnSession: outputs = session.get_outputs() assert len(outputs) == 1 - assert outputs[0].name is None + assert outputs[0].name == "output.1" assert outputs[0].shape == (1, 3, 224, 224) def test_run(self, ann_session: mock.Mock, mocker: MockerFixture) -> None: @@ -781,89 +780,189 @@ class TestCLIP: assert np.allclose(tokens["attention_mask"], np.array([mock_attention_mask], dtype=np.int32), atol=0) +def make_scrfd_heads(detections: list[tuple[int, int, float]]) -> list[np.ndarray]: + """Build the 9 head tensors a SCRFD keypoint model emits at 640x640. + + `detections` is a list of (cell_x, cell_y, score) placed on the stride-8 level. + Distances and keypoint offsets are fixed so the decoded geometry is known by + hand rather than derived from the code under test: + box = [cx - 1*8, cy - 2*8, cx + 3*8, cy + 4*8] + kps = [cx, cy] + [0, 1, 2, ... 9] * 8, reshaped to 5 points + """ + heads: list[np.ndarray] = [] + counts = [(640 // stride) ** 2 * 2 for stride in (8, 16, 32)] + for channels in (1, 4, 10): + for n in counts: + heads.append(np.zeros((n, channels), dtype=np.float32)) + for cell_x, cell_y, score in detections: + i = 2 * (cell_y * 80 + cell_x) # anchor-major, 2 anchors per cell + heads[0][i] = score + heads[3][i] = [1, 2, 3, 4] + heads[6][i] = np.arange(10) + return heads + + +def expected_box(cell_x: int, cell_y: int) -> list[float]: + cx, cy = cell_x * 8, cell_y * 8 + return [cx - 8, cy - 16, cx + 24, cy + 32] + + +def expected_landmarks(cell_x: int, cell_y: int) -> np.ndarray: + cx, cy = cell_x * 8, cell_y * 8 + return (np.tile([cx, cy], 5) + np.arange(10) * 8).reshape(5, 2).astype(np.float32) + + +@pytest.fixture +def batch_axis(mocker: MockerFixture) -> SimpleNamespace: + """Patches for the `_add_batch_axis` path: the onnx module it rewrites the model + through, and the download it would otherwise trigger on load.""" + mocker.patch("immich_ml.models.base.InferenceModel.download") + return SimpleNamespace( + onnx=mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True), + update_dims=mocker.patch( + "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True + ), + ) + + class TestFaceRecognition: - def test_set_min_score(self, snapshot_download: mock.Mock, ort_session: mock.Mock, path: mock.Mock) -> None: - path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" - - face_detector = FaceDetector("buffalo_s", min_score=0.5, cache_dir="test_cache") - face_detector.load() - - assert face_detector.min_score == 0.5 - assert face_detector.model.det_thresh == 0.5 - - def test_detection(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None: + def test_detection(self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture) -> None: mocker.patch.object(FaceDetector, "load") - face_detector = FaceDetector("buffalo_s", min_score=0.0, cache_dir="test_cache") + face_detector = FaceDetector("buffalo_s", cache_dir="test_cache") - det_model = mock.Mock() - num_faces = 2 - bbox = np.random.rand(num_faces, 4).astype(np.float32) - scores = np.array([[0.67]] * num_faces).astype(np.float32) - kpss = np.random.rand(num_faces, 5, 2).astype(np.float32) - det_model.detect.return_value = (np.concatenate([bbox, scores], axis=-1), kpss) - face_detector.model = det_model + session = stub_session((1, 3, 640, 640), outputs=make_scrfd_heads([(10, 10, 0.9), (50, 50, 0.8)])) + face_detector.session = session - faces = face_detector.predict(cv_image) + faces = face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.7) assert isinstance(faces, dict) - assert isinstance(faces.get("boxes", None), np.ndarray) - assert isinstance(faces.get("landmarks", None), np.ndarray) - assert isinstance(faces.get("scores", None), np.ndarray) - assert np.equal(faces["boxes"], bbox.round()).all() - assert np.equal(faces["landmarks"], kpss).all() - assert np.equal(faces["scores"], scores).all() - det_model.detect.assert_called_once() + assert set(faces) == {"boxes", "scores", "landmarks"} + # NMS returns highest score first + assert faces["boxes"].tolist() == [expected_box(10, 10), expected_box(50, 50)] + assert np.allclose(faces["scores"], [0.9, 0.8]) + assert faces["landmarks"].shape == (2, 5, 2) + assert np.allclose(faces["landmarks"][0], expected_landmarks(10, 10)) + assert np.allclose(faces["landmarks"][1], expected_landmarks(50, 50)) - def test_recognition(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None: + def test_detection_applies_min_score_per_request( + self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture + ) -> None: + mocker.patch.object(FaceDetector, "load") + face_detector = FaceDetector("buffalo_s", cache_dir="test_cache") + + session = stub_session((1, 3, 640, 640), outputs=make_scrfd_heads([(10, 10, 0.9), (50, 50, 0.5)])) + face_detector.session = session + + # the threshold is a request parameter, so the same loaded model must honour both + assert face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.7)["boxes"].shape[0] == 1 + assert face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.4)["boxes"].shape[0] == 2 + + def test_detection_scales_boxes_back_to_the_original_image( + self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture + ) -> None: + mocker.patch.object(FaceDetector, "load") + face_detector = FaceDetector("buffalo_s", cache_dir="test_cache") + + session = stub_session((1, 3, 640, 640), outputs=make_scrfd_heads([(10, 10, 0.9)])) + face_detector.session = session + + # a 320x320 image is letterboxed up to 640, so coordinates come back halved + faces = face_detector.predict(Image.new("RGB", (320, 320)), minScore=0.7) + + assert faces["boxes"].tolist() == [[v / 2 for v in expected_box(10, 10)]] + assert np.allclose(faces["landmarks"][0], expected_landmarks(10, 10) / 2) + + def test_recognition(self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture) -> None: mocker.patch.object(FaceRecognizer, "load") mocker.patch( "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", return_value=["CPUExecutionProvider"], ) - face_recognizer = FaceRecognizer("buffalo_s", min_score=0.0, cache_dir="test_cache") + face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache") + # a uniform grey image whose crops land wholly inside it, so every sampled + # pixel is 128 and the normalised value the session receives is exact + image = Image.new("RGB", (600, 800), (128, 128, 128)) num_faces = 2 + arcface_dst = np.array( + [[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]], + dtype=np.float32, + ) + kpss = np.stack([arcface_dst * 2 + [200, 300], arcface_dst * 2 + [220, 320]]).astype(np.float32) bbox = np.random.rand(num_faces, 4).astype(np.float32) scores = np.array([0.67] * num_faces).astype(np.float32) - kpss = np.random.rand(num_faces, 5, 2).astype(np.float32) - faces = {"boxes": bbox, "landmarks": kpss, "scores": scores} + embeddings = np.random.rand(num_faces, 512).astype(np.float32) - rec_model = mock.Mock() - embedding = np.random.rand(num_faces, 512).astype(np.float32) - rec_model.get_feat.return_value = embedding - face_recognizer.model = rec_model + session = stub_session(("batch", 3, 112, 112), outputs=[embeddings]) + face_recognizer.session = session - faces = face_recognizer.predict(cv_image, faces) + faces = face_recognizer.predict(image, {"boxes": bbox, "landmarks": kpss, "scores": scores}) assert isinstance(faces, list) assert len(faces) == num_faces for face in faces: - assert isinstance(face.get("boundingBox"), dict) assert set(face["boundingBox"]) == {"x1", "y1", "x2", "y2"} assert all(isinstance(val, np.float32) for val in face["boundingBox"].values()) - embedding_str = face.get("embedding") - assert isinstance(embedding_str, str) - embedding = orjson.loads(embedding_str) + embedding = orjson.loads(face["embedding"]) assert isinstance(embedding, list) assert len(embedding) == 512 assert isinstance(face.get("score", None), np.float32) - rec_model.get_feat.assert_called_once() - call_args = rec_model.get_feat.call_args_list[0].args - assert len(call_args) == 1 - assert isinstance(call_args[0], list) - assert isinstance(call_args[0][0], np.ndarray) - assert call_args[0][0].shape == (112, 112, 3) + session.run.assert_called_once() + crops = session.run.call_args.args[1]["input.1"] + assert crops.shape == (num_faces, 3, 112, 112) + assert crops.dtype == np.float32 + # mean/std 127.5, not raw 0-255. atol is loose enough for the float32 cancellation + # in normalize's scale-then-subtract, but still rejects a wrong mean or std + assert np.allclose(crops, (128 - 127.5) / 127.5, atol=1e-6) + + def test_recognition_returns_early_without_faces(self, pil_image: Image.Image, mocker: MockerFixture) -> None: + mocker.patch.object(FaceRecognizer, "load") + mocker.patch( + "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", + return_value=["CPUExecutionProvider"], + ) + face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache") + session = mock.Mock() + face_recognizer.session = session + + empty = { + "boxes": np.empty((0, 4), dtype=np.float32), + "landmarks": np.empty((0, 5, 2), dtype=np.float32), + "scores": np.empty(0, dtype=np.float32), + } + + assert face_recognizer.predict(pil_image, empty) == [] + session.run.assert_not_called() + + def test_recognition_batches_when_batch_size_is_set( + self, pil_image: Image.Image, stub_session: Callable[..., mock.Mock], mocker: MockerFixture + ) -> None: + mocker.patch.object(FaceRecognizer, "load") + mocker.patch( + "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", + return_value=["CPUExecutionProvider"], + ) + face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache") + face_recognizer.batch_size = 2 + + num_faces = 5 + session = stub_session((1, 3, 112, 112)) + session.run.side_effect = lambda _, feed: [np.zeros((feed["input.1"].shape[0], 512), dtype=np.float32)] + face_recognizer.session = session + + faces = { + "boxes": np.random.rand(num_faces, 4).astype(np.float32), + "landmarks": (np.random.rand(num_faces, 5, 2) * 100).astype(np.float32), + "scores": np.array([0.67] * num_faces, dtype=np.float32), + } + assert len(face_recognizer.predict(pil_image, faces)) == num_faces + assert session.run.call_count == 3 # 2 + 2 + 1 + assert [c.args[1]["input.1"].shape[0] for c in session.run.call_args_list] == [2, 2, 1] def test_recognition_adds_batch_axis_for_ort( - self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + self, batch_axis: SimpleNamespace, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") mocker.patch( "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", return_value=["CPUExecutionProvider"], @@ -884,24 +983,20 @@ class TestFaceRecognition: output_dims.type.tensor_type.shape.dim = [SimpleNamespace(dim_value=size) for size in [1, 800]] proto.graph.output = [output_dims] - onnx.load.return_value = proto + batch_axis.onnx.load.return_value = proto face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path) face_recognizer.load() assert face_recognizer.batch_size is None - update_dims.assert_called_once_with(proto, {"input.1": ["batch", 3, 224, 224]}, {"output.1": ["batch", 800]}) - onnx.save.assert_called_once_with(update_dims.return_value, face_recognizer.model_path) + batch_axis.update_dims.assert_called_once_with( + proto, {"input.1": ["batch", 3, 224, 224]}, {"output.1": ["batch", 800]} + ) + batch_axis.onnx.save.assert_called_once_with(batch_axis.update_dims.return_value, face_recognizer.model_path) def test_recognition_does_not_add_batch_axis_if_exists( - self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + self, batch_axis: SimpleNamespace, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") mocker.patch( "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", return_value=["CPUExecutionProvider"], @@ -916,88 +1011,53 @@ class TestFaceRecognition: face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path) face_recognizer.load() + # batching is available here, so the axis is only skipped because it already exists assert face_recognizer.batch_size is None - update_dims.assert_not_called() - onnx.load.assert_not_called() - onnx.save.assert_not_called() + batch_axis.update_dims.assert_not_called() + batch_axis.onnx.load.assert_not_called() + batch_axis.onnx.save.assert_not_called() - def test_recognition_does_not_add_batch_axis_for_armnn( - self, ann_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + @pytest.mark.parametrize( + ("session_fixture", "suffix", "model_kwargs", "available_providers"), + [ + pytest.param("ann_session", ".armnn", {"model_format": ModelFormat.ARMNN}, None, id="armnn"), + pytest.param( + "ort_session", ".onnx", {}, ["OpenVINOExecutionProvider", "CPUExecutionProvider"], id="openvino" + ), + pytest.param( + "ort_session", ".onnx", {}, ["MIGraphXExecutionProvider", "CPUExecutionProvider"], id="migraphx" + ), + ], + ) + def test_recognition_does_not_add_batch_axis_when_batching_is_unsupported( + self, + request: pytest.FixtureRequest, + batch_axis: SimpleNamespace, + path: mock.Mock, + ort_pybind: mock.Mock, + mocker: MockerFixture, + session_fixture: str, + suffix: str, + model_kwargs: dict[str, Any], + available_providers: list[str] | None, ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") - path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".armnn" - - inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))] - outputs = [SimpleNamespace(name="output.1", shape=("batch", 800))] - ann_session.return_value.get_inputs.return_value = inputs - ann_session.return_value.get_outputs.return_value = outputs - - face_recognizer = FaceRecognizer("buffalo_s", model_format=ModelFormat.ARMNN, cache_dir=path) - face_recognizer.load() - - assert face_recognizer.batch_size == 1 - update_dims.assert_not_called() - onnx.load.assert_not_called() - onnx.save.assert_not_called() - - def test_recognition_does_not_add_batch_axis_for_openvino( - self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture - ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") - path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" - - inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))] - outputs = [SimpleNamespace(name="output.1", shape=("batch", 800))] - ort_session.return_value.get_inputs.return_value = inputs - ort_session.return_value.get_outputs.return_value = outputs - - face_recognizer = FaceRecognizer( - "buffalo_s", model_format=ModelFormat.ARMNN, cache_dir=path, providers=["OpenVINOExecutionProvider"] - ) - face_recognizer.load() - - assert face_recognizer.batch_size == 1 - update_dims.assert_not_called() - onnx.load.assert_not_called() - onnx.save.assert_not_called() - - def test_recognition_does_not_add_batch_axis_for_migraphx( - self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture - ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") + session = request.getfixturevalue(session_fixture) + ort_pybind.get_available_openvino_device_ids.return_value = ["CPU"] mocker.patch( "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", - return_value=["MIGraphXExecutionProvider", "CPUExecutionProvider"], + return_value=available_providers, ) - path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = suffix + session.return_value.get_inputs.return_value = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))] + session.return_value.get_outputs.return_value = [SimpleNamespace(name="output.1", shape=(1, 800))] - inputs = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))] - outputs = [SimpleNamespace(name="output.1", shape=(1, 800))] - ort_session.return_value.get_inputs.return_value = inputs - ort_session.return_value.get_outputs.return_value = outputs - - face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path) + face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path, **model_kwargs) face_recognizer.load() assert face_recognizer.batch_size == 1 - update_dims.assert_not_called() - onnx.load.assert_not_called() - onnx.save.assert_not_called() + batch_axis.update_dims.assert_not_called() + batch_axis.onnx.load.assert_not_called() + batch_axis.onnx.save.assert_not_called() def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None: mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2)) @@ -1045,10 +1105,10 @@ class TestOcr: rapid_recognizer.assert_called_once_with( OcrOptions( - session=ort_session.return_value, - rec_batch_num=6, - rec_img_shape=(3, 48, 320), - model_root_dir=text_recognizer.cache_dir, + session=ort_session.return_value, + rec_batch_num=6, + rec_img_shape=(3, 48, 320), + model_root_dir=text_recognizer.cache_dir, ) ) @@ -1063,10 +1123,10 @@ class TestOcr: rapid_recognizer.assert_called_once_with( OcrOptions( - session=ort_session.return_value, - rec_batch_num=4, - rec_img_shape=(3, 48, 320), - model_root_dir=text_recognizer.cache_dir, + session=ort_session.return_value, + rec_batch_num=4, + rec_img_shape=(3, 48, 320), + model_root_dir=text_recognizer.cache_dir, ) ) @@ -1083,10 +1143,10 @@ class TestOcr: rapid_recognizer.assert_called_once_with( OcrOptions( - session=ort_session.return_value, - rec_batch_num=6, - rec_img_shape=(3, 48, 320), - model_root_dir=text_recognizer.cache_dir, + session=ort_session.return_value, + rec_batch_num=6, + rec_img_shape=(3, 48, 320), + model_root_dir=text_recognizer.cache_dir, ) ) diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 8a04d34711..fa92147445 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -25,23 +25,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/d7/15d67e05b235d1ed8c3ce61688fe4d84130e72af1657acadfaac3479f4cf/aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d", size = 28199, upload-time = "2024-09-25T13:20:22.688Z" }, ] -[[package]] -name = "albumentations" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "pyyaml" }, - { name = "qudida" }, - { name = "scikit-image" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/d6/8dd5b690d28a332a0b2c3179a345808b5d4c7ad5ddc079b7e116098dff35/albumentations-1.3.1.tar.gz", hash = "sha256:a6a38388fe546c568071e8c82f414498e86c9ed03c08b58e7a88b31cf7a244c6", size = 176371, upload-time = "2023-06-10T07:44:32.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f6/c486cedb4f75147232f32ec4c97026714cfef7c7e247a1f0427bc5489f66/albumentations-1.3.1-py3-none-any.whl", hash = "sha256:6b641d13733181d9ecdc29550e6ad580d1bfa9d25e2213a66940062f25e291bd", size = 125706, upload-time = "2023-06-10T07:44:30.373Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -326,88 +309,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/28/d28211d29bcc3620b1fece85a65ce5bb22f18670a03cd28ea4b75ede270c/configargparse-1.7.1-py3-none-any.whl", hash = "sha256:8b586a31f9d873abd1ca527ffbe58863c99f36d896e2829779803125e83be4b6", size = 25607, upload-time = "2025-05-23T14:26:15.923Z" }, ] -[[package]] -name = "contourpy" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, -] - [[package]] name = "coverage" version = "7.13.4" @@ -512,46 +413,6 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - -[[package]] -name = "cython" -version = "3.0.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/09/ffb61f29b8e3d207c444032b21328327d753e274ea081bc74e009827cc81/Cython-3.0.8.tar.gz", hash = "sha256:8333423d8fd5765e7cceea3a9985dd1e0a5dfeb2734629e1a2ed2d6233d39de6", size = 2744096, upload-time = "2024-01-10T11:01:02.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/a7/f4a0bc9a80e23b380daa2ebb4879bf434aaa0b3b91f7ad8a7f9762b4bd1b/Cython-3.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aae26f9663e50caf9657148403d9874eea41770ecdd6caf381d177c2b1bb82ba", size = 3113615, upload-time = "2024-01-10T11:34:05.899Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/e9295df74246c165b91253a473bfa179debf739c9bee961cbb3ae56c2b79/Cython-3.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:547eb3cdb2f8c6f48e6865d5a741d9dd051c25b3ce076fbca571727977b28ac3", size = 3436320, upload-time = "2024-01-10T11:02:08.689Z" }, - { url = "https://files.pythonhosted.org/packages/26/2c/6a887c957aa53e44f928119dea628a5dfacc8e875424034f5fecac9daba4/Cython-3.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a567d4b9ba70b26db89d75b243529de9e649a2f56384287533cf91512705bee", size = 3591755, upload-time = "2024-01-10T11:02:11.773Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b8/f9c97bae6281da50b3ecb1f7fef0f7f7851eae084609b364717a2b366bf1/Cython-3.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51d1426263b0e82fb22bda8ea60dc77a428581cc19e97741011b938445d383f1", size = 3636099, upload-time = "2024-01-10T11:02:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/17/ae/cd055c2c081c67a6fcad1d8d17d82bd6395b14c6741e3a938f40318c8bc5/Cython-3.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c26daaeccda072459b48d211415fd1e5507c06bcd976fa0d5b8b9f1063467d7b", size = 3458119, upload-time = "2024-01-10T11:02:19.103Z" }, - { url = "https://files.pythonhosted.org/packages/72/ab/ac6f5548d6194f4bb2fc8c6c996aa7369f0fa1403e4d4de787d9e9309b27/Cython-3.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:289ce7838208211cd166e975865fd73b0649bf118170b6cebaedfbdaf4a37795", size = 3614418, upload-time = "2024-01-10T11:02:22.732Z" }, - { url = "https://files.pythonhosted.org/packages/70/e2/3e3e448b7a94887bec3235bcb71957b6681dc42b4536459f8f54d46fa936/Cython-3.0.8-cp311-cp311-win32.whl", hash = "sha256:c8aa05f5e17f8042a3be052c24f2edc013fb8af874b0bf76907d16c51b4e7871", size = 2572819, upload-time = "2024-01-10T11:02:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/85/7d/58635941dfbb5b4e197adb88080b9cbfb230dc3b75683698a530a1989bdb/Cython-3.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:000dc9e135d0eec6ecb2b40a5b02d0868a2f8d2e027a41b0fe16a908a9e6de02", size = 2784167, upload-time = "2024-01-10T11:02:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8e/28f8c6109990eef7317ab7e43644092b49a88a39f9373dcd19318946df09/Cython-3.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:90d3fe31db55685d8cb97d43b0ec39ef614fcf660f83c77ed06aa670cb0e164f", size = 3135638, upload-time = "2024-01-10T11:34:22.889Z" }, - { url = "https://files.pythonhosted.org/packages/83/1f/4720cb682b8ed1ab9749dea35351a66dd29b6a022628cce038415660c384/Cython-3.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e24791ddae2324e88e3c902a765595c738f19ae34ee66bfb1a6dac54b1833419", size = 3340052, upload-time = "2024-01-10T11:02:32.471Z" }, - { url = "https://files.pythonhosted.org/packages/8a/47/ec3fceb9e8f7d6fa130216b8740038e1df7c8e5f215bba363fcf1272a6c1/Cython-3.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f020fa1c0552052e0660790b8153b79e3fc9a15dbd8f1d0b841fe5d204a6ae6", size = 3510079, upload-time = "2024-01-10T11:02:35.312Z" }, - { url = "https://files.pythonhosted.org/packages/71/31/b458127851e248effb909e2791b55870914863cde7c60b94db5ee65d7867/Cython-3.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18bfa387d7a7f77d7b2526af69a65dbd0b731b8d941aaff5becff8e21f6d7717", size = 3573972, upload-time = "2024-01-10T11:02:39.044Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d5/ca6513844d0634abd05ba12304053a454bb70441a9520afa9897d4300156/Cython-3.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fe81b339cffd87c0069c6049b4d33e28bdd1874625ee515785bf42c9fdff3658", size = 3356158, upload-time = "2024-01-10T11:02:42.125Z" }, - { url = "https://files.pythonhosted.org/packages/33/59/98a87b6264f4ad45c820db13c4ec657567476efde020c49443cc842a86af/Cython-3.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80fd94c076e1e1b1ee40a309be03080b75f413e8997cddcf401a118879863388", size = 3522312, upload-time = "2024-01-10T11:02:45.056Z" }, - { url = "https://files.pythonhosted.org/packages/2b/cb/132115d07a0b9d4f075e0741db70a5416b424dcd875b2bb0dd805e818222/Cython-3.0.8-cp312-cp312-win32.whl", hash = "sha256:85077915a93e359a9b920280d214dc0cf8a62773e1f3d7d30fab8ea4daed670c", size = 2602579, upload-time = "2024-01-10T11:02:48.368Z" }, - { url = "https://files.pythonhosted.org/packages/b4/69/cb4620287cd9ef461103e122c0a2ae7f7ecf183e02510676fb5a15c95b05/Cython-3.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:0cb2dcc565c7851f75d496f724a384a790fab12d1b82461b663e66605bec429a", size = 2791268, upload-time = "2024-01-10T11:02:51.483Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7f/f584f5d15323feb897d42ef0e9d910649e2150d7a30cf7e7a8cc1d236e6f/Cython-3.0.8-py2.py3-none-any.whl", hash = "sha256:171b27051253d3f9108e9759e504ba59ff06e7f7ba944457f94deaf9c21bf0b6", size = 1168213, upload-time = "2024-01-10T11:00:56.857Z" }, -] - -[[package]] -name = "easydict" -version = "1.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d2/deb3296d08097fedd622d423c0ec8b68b78c1704b3f1545326f6ce05c75c/easydict-1.11.tar.gz", hash = "sha256:dcb1d2ed28eb300c8e46cd371340373abc62f7c14d6dea74fdfc6f1069061c78", size = 6644, upload-time = "2023-10-23T23:01:37.686Z" } - [[package]] name = "fastapi" version = "0.136.3" @@ -627,55 +488,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/d5c79ee252793ffe845d58a913197bfa02ae9a0b5c9bc3dc4b58d477b9e7/flatbuffers-23.5.26-py2.py3-none-any.whl", hash = "sha256:c0ff356da363087b915fde4b8b45bdda73432fc17cddb3c8157472eab1422ad1", size = 26744, upload-time = "2023-05-26T17:35:14.269Z" }, ] -[[package]] -name = "fonttools" -version = "4.61.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, - { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, - { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, - { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, - { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, - { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, - { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, - { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, - { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, - { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, - { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, - { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, - { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, - { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, -] - [[package]] name = "fsspec" version = "2023.12.2" @@ -959,19 +771,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "imageio" -version = "2.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "pillow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/25/38/f4c568318c656352d211eec6954460dc3af0b7583a6682308f8a66e4c19b/imageio-2.33.1.tar.gz", hash = "sha256:78722d40b137bd98f5ec7312119f8aea9ad2049f76f434748eb306b6937cc1ce", size = 387374, upload-time = "2023-12-11T02:26:44.715Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/69/3aaa69cb0748e33e644fda114c9abd3186ce369edd4fca11107e9f39c6a7/imageio-2.33.1-py3-none-any.whl", hash = "sha256:c5094c48ccf6b2e6da8b4061cd95e1209380afafcbeae4a4e280938cce227e1d", size = 313345, upload-time = "2023-12-11T02:26:42.724Z" }, -] - [[package]] name = "immich-ml" version = "3.1.0" @@ -981,8 +780,8 @@ dependencies = [ { name = "fastapi" }, { name = "gunicorn" }, { name = "huggingface-hub" }, - { name = "insightface" }, { name = "numpy" }, + { name = "onnx" }, { name = "opencv-python-headless" }, { name = "orjson" }, { name = "pillow" }, @@ -1062,8 +861,8 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.95.2,<1.0" }, { name = "gunicorn", specifier = ">=21.1.0" }, { name = "huggingface-hub", specifier = ">=1.0,<2.0" }, - { name = "insightface", specifier = ">=0.7.3,<2.0" }, { name = "numpy", specifier = ">=2.4.0,<3.0" }, + { name = "onnx", specifier = ">=1.22.0" }, { name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" }, @@ -1133,27 +932,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, ] -[[package]] -name = "insightface" -version = "0.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "albumentations" }, - { name = "cython" }, - { name = "easydict" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "onnx" }, - { name = "pillow" }, - { name = "prettytable" }, - { name = "requests" }, - { name = "scikit-image" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/8d/0f4af90999ca96cf8cb846eb5ae27c5ef5b390f9c090dd19e4fa76364c13/insightface-0.7.3.tar.gz", hash = "sha256:f191f719612ebb37018f41936814500544cd0f86e6fcd676c023f354c668ddf7", size = 439490, upload-time = "2023-04-02T08:01:54.541Z" } - [[package]] name = "itsdangerous" version = "2.1.2" @@ -1175,65 +953,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271, upload-time = "2024-05-05T23:41:59.928Z" }, ] -[[package]] -name = "joblib" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/0f/d3b33b9f106dddef461f6df1872b7881321b247f3d255b87f61a7636f7fe/joblib-1.3.2.tar.gz", hash = "sha256:92f865e621e17784e7955080b6d042489e3b8e294949cc44c6eac304f59772b1", size = 1987720, upload-time = "2023-08-09T09:23:40.503Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/40/d551139c85db202f1f384ba8bcf96aca2f329440a844f924c8a0040b6d02/joblib-1.3.2-py3-none-any.whl", hash = "sha256:ef4331c65f239985f3f2220ecc87db222f08fd22097a3dd5698f693875f8cbb9", size = 302207, upload-time = "2023-08-09T09:23:34.583Z" }, -] - -[[package]] -name = "kiwisolver" -version = "1.4.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/2d/226779e405724344fc678fcc025b812587617ea1a48b9442628b688e85ea/kiwisolver-1.4.5.tar.gz", hash = "sha256:e57e563a57fb22a142da34f38acc2fc1a5c864bc29ca1517a88abc963e60d6ec", size = 97552, upload-time = "2023-08-24T09:30:39.861Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/eb/9e099ad7c47c279995d2d20474e1821100a5f10f847739bd65b1c1f02442/kiwisolver-1.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:11863aa14a51fd6ec28688d76f1735f8f69ab1fabf388851a595d0721af042f5", size = 127403, upload-time = "2023-08-24T09:28:39.3Z" }, - { url = "https://files.pythonhosted.org/packages/a6/94/695922e71288855fc7cace3bdb52edda9d7e50edba77abb0c9d7abb51e96/kiwisolver-1.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8ab3919a9997ab7ef2fbbed0cc99bb28d3c13e6d4b1ad36e97e482558a91be90", size = 68156, upload-time = "2023-08-24T09:28:40.301Z" }, - { url = "https://files.pythonhosted.org/packages/4a/fe/23d7fa78f7c66086d196406beb1fb2eaf629dd7adc01c3453033303d17fa/kiwisolver-1.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fcc700eadbbccbf6bc1bcb9dbe0786b4b1cb91ca0dcda336eef5c2beed37b797", size = 66166, upload-time = "2023-08-24T09:28:41.235Z" }, - { url = "https://files.pythonhosted.org/packages/f1/68/f472bf16c9141bb1bea5c0b8c66c68fc1ccb048efdbd8f0872b92125724e/kiwisolver-1.4.5-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfdd7c0b105af050eb3d64997809dc21da247cf44e63dc73ff0fd20b96be55a9", size = 1334300, upload-time = "2023-08-24T09:28:42.409Z" }, - { url = "https://files.pythonhosted.org/packages/8d/26/b4569d1f29751fca22ee915b4ebfef5974f4ef239b3335fc072882bd62d9/kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76c6a5964640638cdeaa0c359382e5703e9293030fe730018ca06bc2010c4437", size = 1426579, upload-time = "2023-08-24T09:28:43.677Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a3/804fc7c8bf233806ec0321c9da35971578620f2ab4fafe67d76100b3ce52/kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbea0db94288e29afcc4c28afbf3a7ccaf2d7e027489c449cf7e8f83c6346eb9", size = 1541360, upload-time = "2023-08-24T09:28:45.939Z" }, - { url = "https://files.pythonhosted.org/packages/07/ef/286e1d26524854f6fbd6540e8364d67a8857d61038ac743e11edc42fe217/kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ceec1a6bc6cab1d6ff5d06592a91a692f90ec7505d6463a88a52cc0eb58545da", size = 1470091, upload-time = "2023-08-24T09:28:47.959Z" }, - { url = "https://files.pythonhosted.org/packages/17/ba/17a706b232308e65f57deeccae503c268292e6a091313f6ce833a23093ea/kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:040c1aebeda72197ef477a906782b5ab0d387642e93bda547336b8957c61022e", size = 1426259, upload-time = "2023-08-24T09:28:49.224Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f3/a0925611c9d6c2f37c5935a39203cadec6883aa914e013b46c84c4c2e641/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f91de7223d4c7b793867797bacd1ee53bfe7359bd70d27b7b58a04efbb9436c8", size = 1847516, upload-time = "2023-08-24T09:28:50.979Z" }, - { url = "https://files.pythonhosted.org/packages/da/85/82d59bb8f7c4c9bb2785138b72462cb1b161668f8230c58bbb28c0403cd5/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:faae4860798c31530dd184046a900e652c95513796ef51a12bc086710c2eec4d", size = 1946228, upload-time = "2023-08-24T09:28:52.812Z" }, - { url = "https://files.pythonhosted.org/packages/34/3c/6a37f444c0233993881e5db3a6a1775925d4d9d2f2609bb325bb1348ed94/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b0157420efcb803e71d1b28e2c287518b8808b7cf1ab8af36718fd0a2c453eb0", size = 1901716, upload-time = "2023-08-24T09:28:54.115Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7e/180425790efc00adfd47db14e1e341cb4826516982334129012b971121a6/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:06f54715b7737c2fecdbf140d1afb11a33d59508a47bf11bb38ecf21dc9ab79f", size = 1852871, upload-time = "2023-08-24T09:28:55.433Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9a/13c68b2edb1fa74321e60893a9a5829788e135138e68060cf44e2d92d2c3/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fdb7adb641a0d13bdcd4ef48e062363d8a9ad4a182ac7647ec88f695e719ae9f", size = 1870265, upload-time = "2023-08-24T09:28:56.855Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0a/fa56a0fdee5da2b4c79899c0f6bd1aefb29d9438c2d66430e78793571c6b/kiwisolver-1.4.5-cp311-cp311-win32.whl", hash = "sha256:bb86433b1cfe686da83ce32a9d3a8dd308e85c76b60896d58f082136f10bffac", size = 46649, upload-time = "2023-08-24T09:28:58.021Z" }, - { url = "https://files.pythonhosted.org/packages/1e/37/d3c2d4ba2719059a0f12730947bbe1ad5ee8bff89e8c35319dcb2c9ddb4c/kiwisolver-1.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c08e1312a9cf1074d17b17728d3dfce2a5125b2d791527f33ffbe805200a355", size = 56116, upload-time = "2023-08-24T09:28:58.994Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7a/debbce859be1a2711eb8437818107137192007b88d17b5cfdb556f457b42/kiwisolver-1.4.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:32d5cf40c4f7c7b3ca500f8985eb3fb3a7dfc023215e876f207956b5ea26632a", size = 125484, upload-time = "2023-08-24T09:28:59.975Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e0/bf8df75ba93b9e035cc6757dd5dcaf63084fdc1c846ae134e818bd7e0f03/kiwisolver-1.4.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f846c260f483d1fd217fe5ed7c173fb109efa6b1fc8381c8b7552c5781756192", size = 67332, upload-time = "2023-08-24T09:29:01.733Z" }, - { url = "https://files.pythonhosted.org/packages/26/61/58bb691f6880588be3a4801d199bd776032ece07203faf3e4a8b377f7d9b/kiwisolver-1.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ff5cf3571589b6d13bfbfd6bcd7a3f659e42f96b5fd1c4830c4cf21d4f5ef45", size = 64987, upload-time = "2023-08-24T09:29:02.789Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a3/96ac5413068b237c006f54dd8d70114e8756d70e3da7613c5aef20627e22/kiwisolver-1.4.5-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7269d9e5f1084a653d575c7ec012ff57f0c042258bf5db0954bf551c158466e7", size = 1370613, upload-time = "2023-08-24T09:29:03.912Z" }, - { url = "https://files.pythonhosted.org/packages/4d/12/f48539e6e17068b59c7f12f4d6214b973431b8e3ac83af525cafd27cebec/kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da802a19d6e15dffe4b0c24b38b3af68e6c1a68e6e1d8f30148c83864f3881db", size = 1463183, upload-time = "2023-08-24T09:29:05.244Z" }, - { url = "https://files.pythonhosted.org/packages/f3/70/26c99be8eb034cc8e3f62e0760af1fbdc97a842a7cbc252f7978507d41c2/kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3aba7311af82e335dd1e36ffff68aaca609ca6290c2cb6d821a39aa075d8e3ff", size = 1581248, upload-time = "2023-08-24T09:29:06.531Z" }, - { url = "https://files.pythonhosted.org/packages/17/f6/f75f20e543639b09b2de7fc864274a5a9b96cda167a6210a1d9d19306b9d/kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:763773d53f07244148ccac5b084da5adb90bfaee39c197554f01b286cf869228", size = 1508815, upload-time = "2023-08-24T09:29:07.867Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/bc0f22ac108743062ab703f8d6d71c9c7b077b8839fa358700bfb81770b8/kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2270953c0d8cdab5d422bee7d2007f043473f9d2999631c86a223c9db56cbd16", size = 1466042, upload-time = "2023-08-24T09:29:09.403Z" }, - { url = "https://files.pythonhosted.org/packages/75/18/98142500f21d6838bcab49ec919414a1f0c6d049d21ddadf139124db6a70/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d099e745a512f7e3bbe7249ca835f4d357c586d78d79ae8f1dcd4d8adeb9bda9", size = 1885159, upload-time = "2023-08-24T09:29:10.66Z" }, - { url = "https://files.pythonhosted.org/packages/21/49/a241eff9e0ee013368c1d17957f9d345b0957493c3a43d82ebb558c90b0a/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:74db36e14a7d1ce0986fa104f7d5637aea5c82ca6326ed0ec5694280942d1162", size = 1981694, upload-time = "2023-08-24T09:29:12.469Z" }, - { url = "https://files.pythonhosted.org/packages/90/90/9490c3de4788123041b1d600d64434f1eed809a2ce9f688075a22166b289/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e5bab140c309cb3a6ce373a9e71eb7e4873c70c2dda01df6820474f9889d6d4", size = 1941579, upload-time = "2023-08-24T09:29:13.743Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bb/a0cc488ef2aa92d7d304318c8549d3ec8dfe6dd3c2c67a44e3922b77bc4f/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0f114aa76dc1b8f636d077979c0ac22e7cd8f3493abbab152f20eb8d3cda71f3", size = 1888168, upload-time = "2023-08-24T09:29:15.097Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e9/9c0de8e45fef3d63f85eed3b1757f9aa511065942866331ef8b99421f433/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:88a2df29d4724b9237fc0c6eaf2a1adae0cdc0b3e9f4d8e7dc54b16812d2d81a", size = 1908464, upload-time = "2023-08-24T09:29:16.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/60/4f0fd50b08f5be536ea0cef518ac7255d9dab43ca40f3b93b60e3ddf80dd/kiwisolver-1.4.5-cp312-cp312-win32.whl", hash = "sha256:72d40b33e834371fd330fb1472ca19d9b8327acb79a5821d4008391db8e29f20", size = 46473, upload-time = "2023-08-24T09:29:17.956Z" }, - { url = "https://files.pythonhosted.org/packages/63/50/2746566bdf4a6a842d117367d05c90cfb87ac04e9e2845aa1fa21f071362/kiwisolver-1.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:2c5674c4e74d939b9d91dda0fae10597ac7521768fec9e399c70a1f27e2ea2d9", size = 56004, upload-time = "2023-08-24T09:29:19.329Z" }, -] - -[[package]] -name = "lazy-loader" -version = "0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload-time = "2024-04-05T13:03:12.261Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, -] - [[package]] name = "librt" version = "0.11.0" @@ -1374,70 +1093,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/44/dbaf65876e258facd65f586dde158387ab89963e7f2235551afc9c2e24c2/MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb", size = 16979, upload-time = "2023-09-07T16:00:57.77Z" }, ] -[[package]] -name = "matplotlib" -version = "3.10.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/91/f2939bb60b7ebf12478b030e0d7f340247390f402b3b189616aad790c366/matplotlib-3.10.5.tar.gz", hash = "sha256:352ed6ccfb7998a00881692f38b4ca083c691d3e275b4145423704c34c909076", size = 34804044, upload-time = "2025-07-31T18:09:33.805Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/c7/1f2db90a1d43710478bb1e9b57b162852f79234d28e4f48a28cc415aa583/matplotlib-3.10.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dcfc39c452c6a9f9028d3e44d2d721484f665304857188124b505b2c95e1eecf", size = 8239216, upload-time = "2025-07-31T18:07:51.947Z" }, - { url = "https://files.pythonhosted.org/packages/82/6d/ca6844c77a4f89b1c9e4d481c412e1d1dbabf2aae2cbc5aa2da4a1d6683e/matplotlib-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:903352681b59f3efbf4546985142a9686ea1d616bb054b09a537a06e4b892ccf", size = 8102130, upload-time = "2025-07-31T18:07:53.65Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1e/5e187a30cc673a3e384f3723e5f3c416033c1d8d5da414f82e4e731128ea/matplotlib-3.10.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:080c3676a56b8ee1c762bcf8fca3fe709daa1ee23e6ef06ad9f3fc17332f2d2a", size = 8666471, upload-time = "2025-07-31T18:07:55.304Z" }, - { url = "https://files.pythonhosted.org/packages/03/c0/95540d584d7d645324db99a845ac194e915ef75011a0d5e19e1b5cee7e69/matplotlib-3.10.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b4984d5064a35b6f66d2c11d668565f4389b1119cc64db7a4c1725bc11adffc", size = 9500518, upload-time = "2025-07-31T18:07:57.199Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2e/e019352099ea58b4169adb9c6e1a2ad0c568c6377c2b677ee1f06de2adc7/matplotlib-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3967424121d3a46705c9fa9bdb0931de3228f13f73d7bb03c999c88343a89d89", size = 9552372, upload-time = "2025-07-31T18:07:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b7/81/3200b792a5e8b354f31f4101ad7834743ad07b6d620259f2059317b25e4d/matplotlib-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:33775bbeb75528555a15ac29396940128ef5613cf9a2d31fb1bfd18b3c0c0903", size = 8100634, upload-time = "2025-07-31T18:08:01.801Z" }, - { url = "https://files.pythonhosted.org/packages/52/46/a944f6f0c1f5476a0adfa501969d229ce5ae60cf9a663be0e70361381f89/matplotlib-3.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:c61333a8e5e6240e73769d5826b9a31d8b22df76c0778f8480baf1b4b01c9420", size = 7978880, upload-time = "2025-07-31T18:08:03.407Z" }, - { url = "https://files.pythonhosted.org/packages/66/1e/c6f6bcd882d589410b475ca1fc22e34e34c82adff519caf18f3e6dd9d682/matplotlib-3.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:00b6feadc28a08bd3c65b2894f56cf3c94fc8f7adcbc6ab4516ae1e8ed8f62e2", size = 8253056, upload-time = "2025-07-31T18:08:05.385Z" }, - { url = "https://files.pythonhosted.org/packages/53/e6/d6f7d1b59413f233793dda14419776f5f443bcccb2dfc84b09f09fe05dbe/matplotlib-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee98a5c5344dc7f48dc261b6ba5d9900c008fc12beb3fa6ebda81273602cc389", size = 8110131, upload-time = "2025-07-31T18:08:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/66/2b/bed8a45e74957549197a2ac2e1259671cd80b55ed9e1fe2b5c94d88a9202/matplotlib-3.10.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a17e57e33de901d221a07af32c08870ed4528db0b6059dce7d7e65c1122d4bea", size = 8669603, upload-time = "2025-07-31T18:08:09.064Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a7/315e9435b10d057f5e52dfc603cd353167ae28bb1a4e033d41540c0067a4/matplotlib-3.10.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97b9d6443419085950ee4a5b1ee08c363e5c43d7176e55513479e53669e88468", size = 9508127, upload-time = "2025-07-31T18:08:10.845Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d9/edcbb1f02ca99165365d2768d517898c22c6040187e2ae2ce7294437c413/matplotlib-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ceefe5d40807d29a66ae916c6a3915d60ef9f028ce1927b84e727be91d884369", size = 9566926, upload-time = "2025-07-31T18:08:13.186Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d9/6dd924ad5616c97b7308e6320cf392c466237a82a2040381163b7500510a/matplotlib-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:c04cba0f93d40e45b3c187c6c52c17f24535b27d545f757a2fffebc06c12b98b", size = 8107599, upload-time = "2025-07-31T18:08:15.116Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f3/522dc319a50f7b0279fbe74f86f7a3506ce414bc23172098e8d2bdf21894/matplotlib-3.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:a41bcb6e2c8e79dc99c5511ae6f7787d2fb52efd3d805fff06d5d4f667db16b2", size = 7978173, upload-time = "2025-07-31T18:08:21.518Z" }, - { url = "https://files.pythonhosted.org/packages/8d/05/4f3c1f396075f108515e45cb8d334aff011a922350e502a7472e24c52d77/matplotlib-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:354204db3f7d5caaa10e5de74549ef6a05a4550fdd1c8f831ab9bca81efd39ed", size = 8253586, upload-time = "2025-07-31T18:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2c/e084415775aac7016c3719fe7006cdb462582c6c99ac142f27303c56e243/matplotlib-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b072aac0c3ad563a2b3318124756cb6112157017f7431626600ecbe890df57a1", size = 8110715, upload-time = "2025-07-31T18:08:24.675Z" }, - { url = "https://files.pythonhosted.org/packages/52/1b/233e3094b749df16e3e6cd5a44849fd33852e692ad009cf7de00cf58ddf6/matplotlib-3.10.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d52fd5b684d541b5a51fb276b2b97b010c75bee9aa392f96b4a07aeb491e33c7", size = 8669397, upload-time = "2025-07-31T18:08:26.778Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ec/03f9e003a798f907d9f772eed9b7c6a9775d5bd00648b643ebfb88e25414/matplotlib-3.10.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7a09ae2f4676276f5a65bd9f2bd91b4f9fbaedf49f40267ce3f9b448de501f", size = 9508646, upload-time = "2025-07-31T18:08:28.848Z" }, - { url = "https://files.pythonhosted.org/packages/91/e7/c051a7a386680c28487bca27d23b02d84f63e3d2a9b4d2fc478e6a42e37e/matplotlib-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ba6c3c9c067b83481d647af88b4e441d532acdb5ef22178a14935b0b881188f4", size = 9567424, upload-time = "2025-07-31T18:08:30.726Z" }, - { url = "https://files.pythonhosted.org/packages/36/c2/24302e93ff431b8f4173ee1dd88976c8d80483cadbc5d3d777cef47b3a1c/matplotlib-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:07442d2692c9bd1cceaa4afb4bbe5b57b98a7599de4dabfcca92d3eea70f9ebe", size = 8107809, upload-time = "2025-07-31T18:08:33.928Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/423ec6a668d375dad825197557ed8fbdb74d62b432c1ed8235465945475f/matplotlib-3.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:48fe6d47380b68a37ccfcc94f009530e84d41f71f5dae7eda7c4a5a84aa0a674", size = 7978078, upload-time = "2025-07-31T18:08:36.764Z" }, - { url = "https://files.pythonhosted.org/packages/51/17/521fc16ec766455c7bb52cc046550cf7652f6765ca8650ff120aa2d197b6/matplotlib-3.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b80eb8621331449fc519541a7461987f10afa4f9cfd91afcd2276ebe19bd56c", size = 8295590, upload-time = "2025-07-31T18:08:38.521Z" }, - { url = "https://files.pythonhosted.org/packages/f8/12/23c28b2c21114c63999bae129fce7fd34515641c517ae48ce7b7dcd33458/matplotlib-3.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47a388908e469d6ca2a6015858fa924e0e8a2345a37125948d8e93a91c47933e", size = 8158518, upload-time = "2025-07-31T18:08:40.195Z" }, - { url = "https://files.pythonhosted.org/packages/81/f8/aae4eb25e8e7190759f3cb91cbeaa344128159ac92bb6b409e24f8711f78/matplotlib-3.10.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b6b49167d208358983ce26e43aa4196073b4702858670f2eb111f9a10652b4b", size = 8691815, upload-time = "2025-07-31T18:08:42.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ba/450c39ebdd486bd33a359fc17365ade46c6a96bf637bbb0df7824de2886c/matplotlib-3.10.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a8da0453a7fd8e3da114234ba70c5ba9ef0e98f190309ddfde0f089accd46ea", size = 9522814, upload-time = "2025-07-31T18:08:44.914Z" }, - { url = "https://files.pythonhosted.org/packages/89/11/9c66f6a990e27bb9aa023f7988d2d5809cb98aa39c09cbf20fba75a542ef/matplotlib-3.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52c6573dfcb7726a9907b482cd5b92e6b5499b284ffacb04ffbfe06b3e568124", size = 9573917, upload-time = "2025-07-31T18:08:47.038Z" }, - { url = "https://files.pythonhosted.org/packages/b3/69/8b49394de92569419e5e05e82e83df9b749a0ff550d07631ea96ed2eb35a/matplotlib-3.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:a23193db2e9d64ece69cac0c8231849db7dd77ce59c7b89948cf9d0ce655a3ce", size = 8181034, upload-time = "2025-07-31T18:08:48.943Z" }, - { url = "https://files.pythonhosted.org/packages/47/23/82dc435bb98a2fc5c20dffcac8f0b083935ac28286413ed8835df40d0baa/matplotlib-3.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:56da3b102cf6da2776fef3e71cd96fcf22103a13594a18ac9a9b31314e0be154", size = 8023337, upload-time = "2025-07-31T18:08:50.791Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e0/26b6cfde31f5383503ee45dcb7e691d45dadf0b3f54639332b59316a97f8/matplotlib-3.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:96ef8f5a3696f20f55597ffa91c28e2e73088df25c555f8d4754931515512715", size = 8253591, upload-time = "2025-07-31T18:08:53.254Z" }, - { url = "https://files.pythonhosted.org/packages/c1/89/98488c7ef7ea20ea659af7499628c240a608b337af4be2066d644cfd0a0f/matplotlib-3.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:77fab633e94b9da60512d4fa0213daeb76d5a7b05156840c4fd0399b4b818837", size = 8112566, upload-time = "2025-07-31T18:08:55.116Z" }, - { url = "https://files.pythonhosted.org/packages/52/67/42294dfedc82aea55e1a767daf3263aacfb5a125f44ba189e685bab41b6f/matplotlib-3.10.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27f52634315e96b1debbfdc5c416592edcd9c4221bc2f520fd39c33db5d9f202", size = 9513281, upload-time = "2025-07-31T18:08:56.885Z" }, - { url = "https://files.pythonhosted.org/packages/e7/68/f258239e0cf34c2cbc816781c7ab6fca768452e6bf1119aedd2bd4a882a3/matplotlib-3.10.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:525f6e28c485c769d1f07935b660c864de41c37fd716bfa64158ea646f7084bb", size = 9780873, upload-time = "2025-07-31T18:08:59.241Z" }, - { url = "https://files.pythonhosted.org/packages/89/64/f4881554006bd12e4558bd66778bdd15d47b00a1f6c6e8b50f6208eda4b3/matplotlib-3.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1f5f3ec4c191253c5f2b7c07096a142c6a1c024d9f738247bfc8e3f9643fc975", size = 9568954, upload-time = "2025-07-31T18:09:01.244Z" }, - { url = "https://files.pythonhosted.org/packages/06/f8/42779d39c3f757e1f012f2dda3319a89fb602bd2ef98ce8faf0281f4febd/matplotlib-3.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:707f9c292c4cd4716f19ab8a1f93f26598222cd931e0cd98fbbb1c5994bf7667", size = 8237465, upload-time = "2025-07-31T18:09:03.206Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f8/153fd06b5160f0cd27c8b9dd797fcc9fb56ac6a0ebf3c1f765b6b68d3c8a/matplotlib-3.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:21a95b9bf408178d372814de7baacd61c712a62cae560b5e6f35d791776f6516", size = 8108898, upload-time = "2025-07-31T18:09:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/c4b082a382a225fe0d2a73f1f57cf6f6f132308805b493a54c8641006238/matplotlib-3.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a6b310f95e1102a8c7c817ef17b60ee5d1851b8c71b63d9286b66b177963039e", size = 8295636, upload-time = "2025-07-31T18:09:07.306Z" }, - { url = "https://files.pythonhosted.org/packages/30/73/2195fa2099718b21a20da82dfc753bf2af58d596b51aefe93e359dd5915a/matplotlib-3.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:94986a242747a0605cb3ff1cb98691c736f28a59f8ffe5175acaeb7397c49a5a", size = 8158575, upload-time = "2025-07-31T18:09:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/f6/e9/a08cdb34618a91fa08f75e6738541da5cacde7c307cea18ff10f0d03fcff/matplotlib-3.10.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ff10ea43288f0c8bab608a305dc6c918cc729d429c31dcbbecde3b9f4d5b569", size = 9522815, upload-time = "2025-07-31T18:09:11.191Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/34d8b7e0d1bb6d06ef45db01dfa560d5a67b1c40c0b998ce9ccde934bb09/matplotlib-3.10.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6adb644c9d040ffb0d3434e440490a66cf73dbfa118a6f79cd7568431f7a012", size = 9783514, upload-time = "2025-07-31T18:09:13.307Z" }, - { url = "https://files.pythonhosted.org/packages/12/09/d330d1e55dcca2e11b4d304cc5227f52e2512e46828d6249b88e0694176e/matplotlib-3.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4fa40a8f98428f789a9dcacd625f59b7bc4e3ef6c8c7c80187a7a709475cf592", size = 9573932, upload-time = "2025-07-31T18:09:15.335Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3b/f70258ac729aa004aca673800a53a2b0a26d49ca1df2eaa03289a1c40f81/matplotlib-3.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:95672a5d628b44207aab91ec20bf59c26da99de12b88f7e0b1fb0a84a86ff959", size = 8322003, upload-time = "2025-07-31T18:09:17.416Z" }, - { url = "https://files.pythonhosted.org/packages/5b/60/3601f8ce6d76a7c81c7f25a0e15fde0d6b66226dd187aa6d2838e6374161/matplotlib-3.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:2efaf97d72629e74252e0b5e3c46813e9eeaa94e011ecf8084a971a31a97f40b", size = 8153849, upload-time = "2025-07-31T18:09:19.673Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/e921be4e1a5f7aca5194e1f016cb67ec294548e530013251f630713e456d/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:160e125da27a749481eaddc0627962990f6029811dbeae23881833a011a0907f", size = 8233224, upload-time = "2025-07-31T18:09:27.512Z" }, - { url = "https://files.pythonhosted.org/packages/ec/74/a2b9b04824b9c349c8f1b2d21d5af43fa7010039427f2b133a034cb09e59/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac3d50760394d78a3c9be6b28318fe22b494c4fcf6407e8fd4794b538251899b", size = 8098539, upload-time = "2025-07-31T18:09:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/fc/66/cd29ebc7f6c0d2a15d216fb572573e8fc38bd5d6dec3bd9d7d904c0949f7/matplotlib-3.10.5-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c49465bf689c4d59d174d0c7795fb42a21d4244d11d70e52b8011987367ac61", size = 8672192, upload-time = "2025-07-31T18:09:31.407Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1449,39 +1104,43 @@ wheels = [ [[package]] name = "ml-dtypes" -version = "0.5.3" +version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/a7/aad060393123cfb383956dca68402aff3db1e1caffd5764887ed5153f41b/ml_dtypes-0.5.3.tar.gz", hash = "sha256:95ce33057ba4d05df50b1f3cfefab22e351868a843b3b15a46c65836283670c9", size = 692316, upload-time = "2025-07-29T18:39:19.454Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/f1/720cb1409b5d0c05cff9040c0e9fba73fa4c67897d33babf905d5d46a070/ml_dtypes-0.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4a177b882667c69422402df6ed5c3428ce07ac2c1f844d8a1314944651439458", size = 667412, upload-time = "2025-07-29T18:38:25.275Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d5/05861ede5d299f6599f86e6bc1291714e2116d96df003cfe23cc54bcc568/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9849ce7267444c0a717c80c6900997de4f36e2815ce34ac560a3edb2d9a64cd2", size = 4964606, upload-time = "2025-07-29T18:38:27.045Z" }, - { url = "https://files.pythonhosted.org/packages/db/dc/72992b68de367741bfab8df3b3fe7c29f982b7279d341aa5bf3e7ef737ea/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f5ae0309d9f888fd825c2e9d0241102fadaca81d888f26f845bc8c13c1e4ee", size = 4938435, upload-time = "2025-07-29T18:38:29.193Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/d27a930bca31fb07d975a2d7eaf3404f9388114463b9f15032813c98f893/ml_dtypes-0.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:58e39349d820b5702bb6f94ea0cb2dc8ec62ee81c0267d9622067d8333596a46", size = 206334, upload-time = "2025-07-29T18:38:30.687Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d8/6922499effa616012cb8dc445280f66d100a7ff39b35c864cfca019b3f89/ml_dtypes-0.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:66c2756ae6cfd7f5224e355c893cfd617fa2f747b8bbd8996152cbdebad9a184", size = 157584, upload-time = "2025-07-29T18:38:32.187Z" }, - { url = "https://files.pythonhosted.org/packages/0d/eb/bc07c88a6ab002b4635e44585d80fa0b350603f11a2097c9d1bfacc03357/ml_dtypes-0.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:156418abeeda48ea4797db6776db3c5bdab9ac7be197c1233771e0880c304057", size = 663864, upload-time = "2025-07-29T18:38:33.777Z" }, - { url = "https://files.pythonhosted.org/packages/cf/89/11af9b0f21b99e6386b6581ab40fb38d03225f9de5f55cf52097047e2826/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1db60c154989af253f6c4a34e8a540c2c9dce4d770784d426945e09908fbb177", size = 4951313, upload-time = "2025-07-29T18:38:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a9/b98b86426c24900b0c754aad006dce2863df7ce0bb2bcc2c02f9cc7e8489/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b255acada256d1fa8c35ed07b5f6d18bc21d1556f842fbc2d5718aea2cd9e55", size = 4928805, upload-time = "2025-07-29T18:38:38.29Z" }, - { url = "https://files.pythonhosted.org/packages/50/c1/85e6be4fc09c6175f36fb05a45917837f30af9a5146a5151cb3a3f0f9e09/ml_dtypes-0.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:da65e5fd3eea434ccb8984c3624bc234ddcc0d9f4c81864af611aaebcc08a50e", size = 208182, upload-time = "2025-07-29T18:38:39.72Z" }, - { url = "https://files.pythonhosted.org/packages/9e/17/cf5326d6867be057f232d0610de1458f70a8ce7b6290e4b4a277ea62b4cd/ml_dtypes-0.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:8bb9cd1ce63096567f5f42851f5843b5a0ea11511e50039a7649619abfb4ba6d", size = 161560, upload-time = "2025-07-29T18:38:41.072Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/1bcc98a66de7b2455dfb292f271452cac9edc4e870796e0d87033524d790/ml_dtypes-0.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5103856a225465371fe119f2fef737402b705b810bd95ad5f348e6e1a6ae21af", size = 663781, upload-time = "2025-07-29T18:38:42.984Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2c/bd2a79ba7c759ee192b5601b675b180a3fd6ccf48ffa27fe1782d280f1a7/ml_dtypes-0.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cae435a68861660af81fa3c5af16b70ca11a17275c5b662d9c6f58294e0f113", size = 4956217, upload-time = "2025-07-29T18:38:44.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/f3/091ba84e5395d7fe5b30c081a44dec881cd84b408db1763ee50768b2ab63/ml_dtypes-0.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6936283b56d74fbec431ca57ce58a90a908fdbd14d4e2d22eea6d72bb208a7b7", size = 4933109, upload-time = "2025-07-29T18:38:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/bc/24/054036dbe32c43295382c90a1363241684c4d6aaa1ecc3df26bd0c8d5053/ml_dtypes-0.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:d0f730a17cf4f343b2c7ad50cee3bd19e969e793d2be6ed911f43086460096e4", size = 208187, upload-time = "2025-07-29T18:38:48.24Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/7dc3ec6794a4a9004c765e0c341e32355840b698f73fd2daff46f128afc1/ml_dtypes-0.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2db74788fc01914a3c7f7da0763427280adfc9cd377e9604b6b64eb8097284bd", size = 161559, upload-time = "2025-07-29T18:38:50.493Z" }, - { url = "https://files.pythonhosted.org/packages/12/91/e6c7a0d67a152b9330445f9f0cf8ae6eee9b83f990b8c57fe74631e42a90/ml_dtypes-0.5.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93c36a08a6d158db44f2eb9ce3258e53f24a9a4a695325a689494f0fdbc71770", size = 689321, upload-time = "2025-07-29T18:38:52.03Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6c/b7b94b84a104a5be1883305b87d4c6bd6ae781504474b4cca067cb2340ec/ml_dtypes-0.5.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e44a3761f64bc009d71ddb6d6c71008ba21b53ab6ee588dadab65e2fa79eafc", size = 5274495, upload-time = "2025-07-29T18:38:53.797Z" }, - { url = "https://files.pythonhosted.org/packages/5b/38/6266604dffb43378055394ea110570cf261a49876fc48f548dfe876f34cc/ml_dtypes-0.5.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdf40d2aaabd3913dec11840f0d0ebb1b93134f99af6a0a4fd88ffe924928ab4", size = 5285422, upload-time = "2025-07-29T18:38:56.603Z" }, - { url = "https://files.pythonhosted.org/packages/7c/88/8612ff177d043a474b9408f0382605d881eeb4125ba89d4d4b3286573a83/ml_dtypes-0.5.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:aec640bd94c4c85c0d11e2733bd13cbb10438fb004852996ec0efbc6cacdaf70", size = 661182, upload-time = "2025-07-29T18:38:58.414Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2b/0569a5e88b29240d373e835107c94ae9256fb2191d3156b43b2601859eff/ml_dtypes-0.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bda32ce212baa724e03c68771e5c69f39e584ea426bfe1a701cb01508ffc7035", size = 4956187, upload-time = "2025-07-29T18:39:00.611Z" }, - { url = "https://files.pythonhosted.org/packages/51/66/273c2a06ae44562b104b61e6b14444da00061fd87652506579d7eb2c40b1/ml_dtypes-0.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c205cac07d24a29840c163d6469f61069ce4b065518519216297fc2f261f8db9", size = 4930911, upload-time = "2025-07-29T18:39:02.405Z" }, - { url = "https://files.pythonhosted.org/packages/93/ab/606be3e87dc0821bd360c8c1ee46108025c31a4f96942b63907bb441b87d/ml_dtypes-0.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:cd7c0bb22d4ff86d65ad61b5dd246812e8993fbc95b558553624c33e8b6903ea", size = 216664, upload-time = "2025-07-29T18:39:03.927Z" }, - { url = "https://files.pythonhosted.org/packages/30/a2/e900690ca47d01dffffd66375c5de8c4f8ced0f1ef809ccd3b25b3e6b8fa/ml_dtypes-0.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:9d55ea7f7baf2aed61bf1872116cefc9d0c3693b45cae3916897ee27ef4b835e", size = 160203, upload-time = "2025-07-29T18:39:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/53/21/783dfb51f40d2660afeb9bccf3612b99f6a803d980d2a09132b0f9d216ab/ml_dtypes-0.5.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:e12e29764a0e66a7a31e9b8bf1de5cc0423ea72979f45909acd4292de834ccd3", size = 689324, upload-time = "2025-07-29T18:39:07.567Z" }, - { url = "https://files.pythonhosted.org/packages/09/f7/a82d249c711abf411ac027b7163f285487f5e615c3e0716c61033ce996ab/ml_dtypes-0.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19f6c3a4f635c2fc9e2aa7d91416bd7a3d649b48350c51f7f715a09370a90d93", size = 5275917, upload-time = "2025-07-29T18:39:09.339Z" }, - { url = "https://files.pythonhosted.org/packages/7f/3c/541c4b30815ab90ebfbb51df15d0b4254f2f9f1e2b4907ab229300d5e6f2/ml_dtypes-0.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ab039ffb40f3dc0aeeeba84fd6c3452781b5e15bef72e2d10bcb33e4bbffc39", size = 5285284, upload-time = "2025-07-29T18:39:11.532Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5e/712092cfe7e5eb667b8ad9ca7c54442f21ed7ca8979745f1000e24cf8737/ml_dtypes-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90", size = 679734, upload-time = "2025-11-17T22:31:39.223Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cf/912146dfd4b5c0eea956836c01dcd2fce6c9c844b2691f5152aca196ce4f/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040", size = 5056165, upload-time = "2025-11-17T22:31:41.071Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/19189ea605017473660e43762dc853d2797984b3c7bf30ce656099add30c/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483", size = 5034975, upload-time = "2025-11-17T22:31:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/b4/24/70bd59276883fdd91600ca20040b41efd4902a923283c4d6edcb1de128d2/ml_dtypes-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:7c23c54a00ae43edf48d44066a7ec31e05fdc2eee0be2b8b50dd1903a1db94bb", size = 210742, upload-time = "2025-11-17T22:31:44.068Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c9/64230ef14e40aa3f1cb254ef623bf812735e6bec7772848d19131111ac0d/ml_dtypes-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:557a31a390b7e9439056644cb80ed0735a6e3e3bb09d67fd5687e4b04238d1de", size = 160709, upload-time = "2025-11-17T22:31:46.557Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/3c70881695e056f8a32f8b941126cf78775d9a4d7feba8abcb52cb7b04f2/ml_dtypes-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a174837a64f5b16cab6f368171a1a03a27936b31699d167684073ff1c4237dac", size = 676927, upload-time = "2025-11-17T22:31:48.182Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7f7c643e8b1320fd958bf098aa7ecf70623a42ec5154e3be3be673f4c34d900", size = 5028464, upload-time = "2025-11-17T22:31:50.135Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cb/28ce52eb94390dda42599c98ea0204d74799e4d8047a0eb559b6fd648056/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ad459e99793fa6e13bd5b7e6792c8f9190b4e5a1b45c63aba14a4d0a7f1d5ff", size = 5009002, upload-time = "2025-11-17T22:31:52.001Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f0/0cfadd537c5470378b1b32bd859cf2824972174b51b873c9d95cfd7475a5/ml_dtypes-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:c1a953995cccb9e25a4ae19e34316671e4e2edaebe4cf538229b1fc7109087b7", size = 212222, upload-time = "2025-11-17T22:31:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/9acc86985bfad8f2c2d30291b27cd2bb4c74cea08695bd540906ed744249/ml_dtypes-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:9bad06436568442575beb2d03389aa7456c690a5b05892c471215bfd8cf39460", size = 160793, upload-time = "2025-11-17T22:31:55.358Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a1/4008f14bbc616cfb1ac5b39ea485f9c63031c4634ab3f4cf72e7541f816a/ml_dtypes-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48", size = 676888, upload-time = "2025-11-17T22:31:56.907Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/dff378afc2b0d5a7d6cd9d3209b60474d9819d1189d347521e1688a60a53/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b", size = 5036993, upload-time = "2025-11-17T22:31:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/eb/33/40cd74219417e78b97c47802037cf2d87b91973e18bb968a7da48a96ea44/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d", size = 5010956, upload-time = "2025-11-17T22:31:59.931Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/200088c6859d8221454825959df35b5244fa9bdf263fd0249ac5fb75e281/ml_dtypes-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328", size = 212224, upload-time = "2025-11-17T22:32:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dfc3775cb36367816e678f69a7843f6f03bd4e2bcd79941e01ea960a068e/ml_dtypes-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175", size = 160798, upload-time = "2025-11-17T22:32:02.864Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/e9ddb35fd1dd43b1106c20ced3f53c2e8e7fc7598c15638e9f80677f81d4/ml_dtypes-0.5.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6", size = 702083, upload-time = "2025-11-17T22:32:04.08Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/667060b0aed1aa63166b22897fdf16dca9eb704e6b4bbf86848d5a181aa7/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d", size = 5354111, upload-time = "2025-11-17T22:32:05.546Z" }, + { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/1339dc6e2557a344f5ba5590872e80346f76f6cb2ac3dd16e4666e88818c/ml_dtypes-0.5.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2b857d3af6ac0d39db1de7c706e69c7f9791627209c3d6dedbfca8c7e5faec22", size = 673781, upload-time = "2025-11-17T22:32:11.364Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/067b84365c7e83bda15bba2b06c6ca250ce27b20630b1128c435fb7a09aa/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:805cef3a38f4eafae3a5bf9ebdcdb741d0bcfd9e1bd90eb54abd24f928cd2465", size = 5036145, upload-time = "2025-11-17T22:32:12.783Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bb/82c7dcf38070b46172a517e2334e665c5bf374a262f99a283ea454bece7c/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14a4fd3228af936461db66faccef6e4f41c1d82fcc30e9f8d58a08916b1d811f", size = 5010230, upload-time = "2025-11-17T22:32:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e9/93/2bfed22d2498c468f6bcd0d9f56b033eaa19f33320389314c19ef6766413/ml_dtypes-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:8c6a2dcebd6f3903e05d51960a8058d6e131fe69f952a5397e5dbabc841b6d56", size = 221032, upload-time = "2025-11-17T22:32:15.763Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/9c912fe6ea747bb10fe2f8f54d027eb265db05dfb0c6335e3e063e74e6e8/ml_dtypes-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:5a0f68ca8fd8d16583dfa7793973feb86f2fbb56ce3966daf9c9f748f52a2049", size = 163353, upload-time = "2025-11-17T22:32:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/cd/02/48aa7d84cc30ab4ee37624a2fd98c56c02326785750cd212bc0826c2f15b/ml_dtypes-0.5.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:bfc534409c5d4b0bf945af29e5d0ab075eae9eecbb549ff8a29280db822f34f9", size = 702085, upload-time = "2025-11-17T22:32:18.175Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e7/85cb99fe80a7a5513253ec7faa88a65306be071163485e9a626fce1b6e84/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2314892cdc3fcf05e373d76d72aaa15fda9fb98625effa73c1d646f331fcecb7", size = 5355358, upload-time = "2025-11-17T22:32:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/79/2b/a826ba18d2179a56e144aef69e57fb2ab7c464ef0b2111940ee8a3a223a2/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d2ffd05a2575b1519dc928c0b93c06339eb67173ff53acb00724502cda231cf", size = 5366332, upload-time = "2025-11-17T22:32:21.193Z" }, + { url = "https://files.pythonhosted.org/packages/84/44/f4d18446eacb20ea11e82f133ea8f86e2bf2891785b67d9da8d0ab0ef525/ml_dtypes-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4381fe2f2452a2d7589689693d3162e876b3ddb0a832cde7a414f8e1adf7eab1", size = 236612, upload-time = "2025-11-17T22:32:22.579Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" }, ] [[package]] @@ -1606,15 +1265,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, ] -[[package]] -name = "networkx" -version = "3.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/80/a84676339aaae2f1cfdf9f418701dd634aef9cc76f708ef55c36ff39c3ca/networkx-3.2.1.tar.gz", hash = "sha256:9f1bb5cf3409bf324e0a722c20bdb4c20ee39bf1c30ce8ae499c8502b0b5e0c6", size = 2073928, upload-time = "2023-10-28T08:41:39.364Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/f0/8fbc882ca80cf077f1b246c0e3c3465f7f415439bdea6b899f6b19f61f70/networkx-3.2.1-py3-none-any.whl", hash = "sha256:f18c69adc97877c42332c170849c96cefa91881c99a7cb3e95b7c659ebdc1ec2", size = 1647772, upload-time = "2023-10-28T08:41:36.945Z" }, -] - [[package]] name = "numpy" version = "2.4.6" @@ -1709,7 +1359,7 @@ wheels = [ [[package]] name = "onnx" -version = "1.19.1" +version = "1.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ml-dtypes" }, @@ -1717,31 +1367,26 @@ dependencies = [ { name = "protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/2f/c619eb65769357e9b6de9212c9a821ab39cd484448e5d6b3fb5fb0a64c6d/onnx-1.19.1.tar.gz", hash = "sha256:737524d6eb3907d3499ea459c6f01c5a96278bb3a0f2ff8ae04786fb5d7f1ed5", size = 12033525, upload-time = "2025-10-10T04:01:34.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/19/8ea73a64b368b75fe339771a20a02bc61ea1f551484c9e3d9d0bfbd0450f/onnx-1.22.0.tar.gz", hash = "sha256:ef40c0aaf0b643857ea9306fc7eddce17eaf9fb0407e4801f1fc5758443a38e0", size = 12024721, upload-time = "2026-06-15T12:50:05.354Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/07/0019c72924909e4f64b9199770630ab7b8d7914b912b03230e68f5eda7ae/onnx-1.19.1-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:17aaf5832126de0a5197a5864e4f09a764dd7681d3035135547959b4b6b77a09", size = 18320936, upload-time = "2025-10-10T04:00:04.235Z" }, - { url = "https://files.pythonhosted.org/packages/af/2f/5c47acf740dc35f0decc640844260fbbdc0efa0565657c93fd7ff30f13f3/onnx-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01b292a4d0b197c45d8184545bbc8ae1df83466341b604187c1b05902cb9c920", size = 18044269, upload-time = "2025-10-10T04:00:07.449Z" }, - { url = "https://files.pythonhosted.org/packages/d5/61/6c457ee8c3a62a3cad0a4bfa4c5436bb3ac4df90c3551d40bee1224b5b51/onnx-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1839af08ab4a909e4af936b8149c27f8c64b96138981024e251906e0539d8bf9", size = 18218092, upload-time = "2025-10-10T04:00:11.135Z" }, - { url = "https://files.pythonhosted.org/packages/54/d5/ab832e1369505e67926a70e9a102061f89ad01f91aa296c4b1277cb81b25/onnx-1.19.1-cp311-cp311-win32.whl", hash = "sha256:0bdbb676e3722bd32f9227c465d552689f49086f986a696419d865cb4e70b989", size = 16344809, upload-time = "2025-10-10T04:00:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/8b/b5/6eb4611d24b85002f878ba8476b4cecbe6f9784c0236a3c5eff85236cc0a/onnx-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:1346853df5c1e3ebedb2e794cf2a51e0f33759affd655524864ccbcddad7035b", size = 16464319, upload-time = "2025-10-10T04:00:18.235Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ff/f0e1f06420c70e20d497fec7c94a864d069943b6312bedd4224c0ab946f8/onnx-1.19.1-cp311-cp311-win_arm64.whl", hash = "sha256:2d69c280c0e665b7f923f499243b9bb84fe97970b7a4668afa0032045de602c8", size = 16437503, upload-time = "2025-10-10T04:00:21.247Z" }, - { url = "https://files.pythonhosted.org/packages/50/07/f6c5b2cffef8c29e739616d1415aea22f7b7ef1f19c17f02b7cff71f5498/onnx-1.19.1-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:3612193a89ddbce5c4e86150869b9258780a82fb8c4ca197723a4460178a6ce9", size = 18327840, upload-time = "2025-10-10T04:00:24.259Z" }, - { url = "https://files.pythonhosted.org/packages/93/20/0568ebd52730287ae80cac8ac893a7301c793ea1630984e2519ee92b02a9/onnx-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c2fd2f744e7a3880ad0c262efa2edf6d965d0bd02b8f327ec516ad4cb0f2f15", size = 18042539, upload-time = "2025-10-10T04:00:27.693Z" }, - { url = "https://files.pythonhosted.org/packages/14/fd/cd7a0fd10a04f8cc5ae436b63e0022e236fe51b9dbb8ee6317fd48568c72/onnx-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:485d3674d50d789e0ee72fa6f6e174ab81cb14c772d594f992141bd744729d8a", size = 18218271, upload-time = "2025-10-10T04:00:30.495Z" }, - { url = "https://files.pythonhosted.org/packages/65/68/cc8b8c05469fe08384b446304ad7e6256131ca0463bf6962366eebec98c0/onnx-1.19.1-cp312-cp312-win32.whl", hash = "sha256:638bc56ff1a5718f7441e887aeb4e450f37a81c6eac482040381b140bd9ba601", size = 16345111, upload-time = "2025-10-10T04:00:34.982Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5e/d1cb16693598a512c2cf9ffe0841d8d8fd2c83ae8e889efd554f5aa427cf/onnx-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:bc7e2e4e163e679721e547958b5a7db875bf822cad371b7c1304aa4401a7c7a4", size = 16465621, upload-time = "2025-10-10T04:00:39.107Z" }, - { url = "https://files.pythonhosted.org/packages/90/32/da116cc61fdef334782aa7f87a1738431dd1af1a5d1a44bd95d6d51ad260/onnx-1.19.1-cp312-cp312-win_arm64.whl", hash = "sha256:17c215b1c0f20fe93b4cbe62668247c1d2294b9bc7f6be0ca9ced28e980c07b7", size = 16437505, upload-time = "2025-10-10T04:00:42.255Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b8/ab1fdfe2e8502f4dc4289fc893db35816bd20d080d8370f86e74dda5f598/onnx-1.19.1-cp313-cp313-macosx_12_0_universal2.whl", hash = "sha256:4e5f938c68c4dffd3e19e4fd76eb98d298174eb5ebc09319cdd0ec5fe50050dc", size = 18327815, upload-time = "2025-10-10T04:00:45.682Z" }, - { url = "https://files.pythonhosted.org/packages/04/40/eb875745a4b92aea10e5e32aa2830f409c4d7b6f7b48ca1c4eaad96636c5/onnx-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:86e20a5984b017feeef2dbf4ceff1c7c161ab9423254968dd77d3696c38691d0", size = 18041464, upload-time = "2025-10-10T04:00:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/8586135f40dbe4989cec4d413164bc8fc5c73d37c566f33f5ea3a7f2b6f6/onnx-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d9c467f0f29993c12f330736af87972f30adb8329b515f39d63a0db929cb2c", size = 18218244, upload-time = "2025-10-10T04:00:51.891Z" }, - { url = "https://files.pythonhosted.org/packages/51/b5/4201254b8683129db5da3fb55aa1f7e56d0a8d45c66ce875dec21ca1ff25/onnx-1.19.1-cp313-cp313-win32.whl", hash = "sha256:65eee353a51b4e4ca3e797784661e5376e2b209f17557e04921eac9166a8752e", size = 16345330, upload-time = "2025-10-10T04:00:54.858Z" }, - { url = "https://files.pythonhosted.org/packages/69/67/c6d239afbcdbeb6805432969b908b5c9f700c96d332b34e3f99518d76caf/onnx-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:c3bc87e38b53554b1fc9ef7b275c81c6f5c93c90a91935bb0aa8d4d498a6d48e", size = 16465567, upload-time = "2025-10-10T04:00:57.893Z" }, - { url = "https://files.pythonhosted.org/packages/99/fe/89f1e40f5bc54595ff0dcf5391ce19e578b528973ccc74dd99800196d30d/onnx-1.19.1-cp313-cp313-win_arm64.whl", hash = "sha256:e41496f400afb980ec643d80d5164753a88a85234fa5c06afdeebc8b7d1ec252", size = 16437562, upload-time = "2025-10-10T04:01:00.703Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/b186ccbc8fe7e93643a6a6d40bbf2bb6ce4fb9469bbd3453c77e270c50ad/onnx-1.19.1-cp313-cp313t-macosx_12_0_universal2.whl", hash = "sha256:5f6274abf0fd74e80e78ecbb44bd44509409634525c89a9b38276c8af47dc0a2", size = 18355703, upload-time = "2025-10-10T04:01:03.735Z" }, - { url = "https://files.pythonhosted.org/packages/60/f1/22ee4d8b8f9fa4cb1d1b9579da3b4b5187ddab33846ec5ac744af02c0e2b/onnx-1.19.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07dcd4d83584eb4bf8f21ac04c82643712e5e93ac2a0ed10121ec123cb127e1e", size = 18047830, upload-time = "2025-10-10T04:01:06.552Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/8f3d51e3a095d42cdf2039a590cff06d024f2a10efbd0b1a2a6b3825f019/onnx-1.19.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1975860c3e720db25d37f1619976582828264bdcc64fa7511c321ac4fc01add3", size = 18221126, upload-time = "2025-10-10T04:01:09.77Z" }, - { url = "https://files.pythonhosted.org/packages/4f/0d/f9d6c2237083f1aac14b37f0b03b0d81f1147a8e2af0c3828165e0a6a67b/onnx-1.19.1-cp313-cp313t-win_amd64.whl", hash = "sha256:9807d0e181f6070ee3a6276166acdc571575d1bd522fc7e89dba16fd6e7ffed9", size = 16465560, upload-time = "2025-10-10T04:01:13.212Z" }, - { url = "https://files.pythonhosted.org/packages/36/70/8418a58faa7d606d6a92cab69ae8d361b3b3969bf7e7e9a65a86d5d1b674/onnx-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6ee83e6929d75005482d9f304c502ac7c9b8d6db153aa6b484dae74d0f28570", size = 18042812, upload-time = "2025-10-10T04:01:15.919Z" }, + { url = "https://files.pythonhosted.org/packages/0c/55/30825c02c92a0380ce84c3feeeec95d329fa77548ba58cb10ad4bbfd83c6/onnx-1.22.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:2d8f229a553fa440fe623ed7b36fca5e7762da3af871c3f8f8ce451df73e2914", size = 20167891, upload-time = "2026-06-15T12:49:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/4b/24/cd4ab52ecaf41c3fbed674772ccbfe39041cb257b8471a47a37e48bff3f8/onnx-1.22.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a89a7cb9ba13d78f009bdec448ec82a98972589734f157022a2bff7a5973a6", size = 18892720, upload-time = "2026-06-15T12:49:16.904Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/c9d9d56ceadb1c0a90a7cbec5a0510520ab6538938944fa84548e4b5b054/onnx-1.22.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d0a2bdb15eb2b3cb65c438f3423d9620d14fdce32f92380e6bb1b2e09568ef5", size = 19110720, upload-time = "2026-06-15T12:49:19.812Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/e43e5a68d9cadde55df75310027f87127333a77e5ddcea14c73e96a10cac/onnx-1.22.0-cp311-cp311-win32.whl", hash = "sha256:239958534464612fbcb6ed23d5228aaa925b39b8773f58726809ffdccb4edd1c", size = 17083746, upload-time = "2026-06-15T12:49:22.935Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/cc0a9f2cf4522e42829d089927b4b75924d32f50dca237482e7b741df003/onnx-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:8561a2c00041c07e08db0c228593b5b4694100398685f348532af7dbb84189da", size = 17215684, upload-time = "2026-06-15T12:49:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/c9/99/0f049f9eaa06c8383060c5f0a338e3a6caac8822e6e326c9162f05abf95a/onnx-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:8907b9b9389893bc0dc6314cc00ee1e3a69844e48d689eacc6a0340411a7da58", size = 17210398, upload-time = "2026-06-15T12:49:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6a/481561f1093834376ed493e4ca42a73e5be0d50031f2969c86593bdc7c96/onnx-1.22.0-cp312-abi3-macosx_12_0_universal2.whl", hash = "sha256:596fbf0490947533c1c1045ba860851dc9fb77471023dac9a71ba5b42ceab103", size = 20167081, upload-time = "2026-06-15T12:49:32.078Z" }, + { url = "https://files.pythonhosted.org/packages/84/55/b34fc2aa30aa54b4a775402d24c4082242c720283a274fe976ac8eb94480/onnx-1.22.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae5a563f281cd9d2845622cecf6c092a57e4ee1b138f66fdbbdd4200567a5e16", size = 18889249, upload-time = "2026-06-15T12:49:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/09/a6/bd32357e6cc1ecb473afd78193d7231724f284435d2db25696ecfaaa1503/onnx-1.22.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:955e02e1f6d385b53d52f9cd7b9cdf5caf417c300bcfe3c64c6d542be763845b", size = 19106514, upload-time = "2026-06-15T12:49:37.424Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9d/3af461ac6c714b8b369cb71499659932f4f12cfb066250b62f7567c3d530/onnx-1.22.0-cp312-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:82e9f27fc1223cb06d68a56bed6f9d3caf3d0dad1b61bce45006d529b15bd94c", size = 16966387, upload-time = "2026-06-15T12:49:40.918Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/68195b5e5a53e333faf2660f5352ee43738d0e42fc5216cc6b1871a9fbfb/onnx-1.22.0-cp312-abi3-win32.whl", hash = "sha256:cc8b66b312f8f03a53e268afb67180a2d97dd12cc79e2b61361c6c0073448016", size = 17081568, upload-time = "2026-06-15T12:49:43.398Z" }, + { url = "https://files.pythonhosted.org/packages/13/a8/734725bb703c5fabb687f79c79e51249475212b3eb37771ac4a4ac9b487f/onnx-1.22.0-cp312-abi3-win_amd64.whl", hash = "sha256:72ccebab3bac07215c204ce8848d42e78eaaa666badbf72d25cd359b9f269e3a", size = 17213290, upload-time = "2026-06-15T12:49:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/8ce48d8ae26a8761ad4e5dc771961b155c5c3c7c8540ec7f2f2d71b69af0/onnx-1.22.0-cp312-abi3-win_arm64.whl", hash = "sha256:f3c120dcdb70ad738f3c061b32798f408ea299eb69f84dd69ab4a6bf3c2ec01f", size = 17207030, upload-time = "2026-06-15T12:49:48.635Z" }, + { url = "https://files.pythonhosted.org/packages/f3/13/47323b97846387848efb1044ded11bb94b83526f3d1fbdb37c6480d4520f/onnx-1.22.0-cp314-cp314t-macosx_12_0_universal2.whl", hash = "sha256:19e45e4af88e3fe3261458d4b8cc461957ae2782a358a3560503569bf3b23b72", size = 20176465, upload-time = "2026-06-15T12:49:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/13/0c/d3b8a7e7eee123938586c608bb9894b5723f2342b9450c0eec59fbec7099/onnx-1.22.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c21a0e59fd967a95b358e4a6e756d1f1eec2d304a83480f329f66e30d2bf0223", size = 18894028, upload-time = "2026-06-15T12:49:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8a/da2a97ab46fe6e0cd9beb3ac14603a22f5be492f9ca347faf8233a07bb33/onnx-1.22.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2632406b8f523ef2e2873c363f90b20a3d88c0fbcfac757d3addffccf8f452c2", size = 19110420, upload-time = "2026-06-15T12:49:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/ce984063017518307ebfaa545782fc400e593dc2d7fdf4f23ce4be1ed197/onnx-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a3a39fc4643867aecb33417fdddb11e308ee79d2d4a584b9d50cc7aec2091b13", size = 17237547, upload-time = "2026-06-15T12:50:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/257a880384a1dd502d543b0067945074d63cd17d0840e958355bc8197da8/onnx-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8e268cdc0547e3949799ffd4a44451dc2b9080b57d0824a2db680b6ec65506f0", size = 17231391, upload-time = "2026-06-15T12:50:03.047Z" }, ] [[package]] @@ -2058,18 +1703,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, ] -[[package]] -name = "prettytable" -version = "3.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c0/5e9c4d2a643a00a6f67578ef35485173de273a4567279e4f0c200c01386b/prettytable-3.9.0.tar.gz", hash = "sha256:f4ed94803c23073a90620b201965e5dc0bccf1760b7a7eaf3158cab8aaffdf34", size = 47874, upload-time = "2023-09-11T14:04:14.548Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/81/316b6a55a0d1f327d04cc7b0ba9d04058cb62de6c3a4d4b0df280cbe3b0b/prettytable-3.9.0-py3-none-any.whl", hash = "sha256:a71292ab7769a5de274b146b276ce938786f56c31cf7cea88b6f3775d82fe8c8", size = 27772, upload-time = "2023-09-11T14:03:45.582Z" }, -] - [[package]] name = "protobuf" version = "6.33.2" @@ -2274,15 +1907,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/9c/372fef8377a6e340b1704768d20daaded98bf13282b5327beb2e2fe2c7ef/pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", size = 1179756, upload-time = "2023-11-21T20:43:49.423Z" }, ] -[[package]] -name = "pyparsing" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/fe/65c989f70bd630b589adfbbcd6ed238af22319e90f059946c26b4835e44b/pyparsing-3.1.1.tar.gz", hash = "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db", size = 884814, upload-time = "2023-07-30T15:07:02.617Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -2338,18 +1962,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, -] - [[package]] name = "python-dotenv" version = "1.0.0" @@ -2511,21 +2123,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] -[[package]] -name = "qudida" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "scikit-learn" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3e/2d/bab8babd9dc9a9e4df6eb115540cee4322c1a74078fb6f3b3ebc452a22b3/qudida-0.0.4.tar.gz", hash = "sha256:db198e2887ab0c9aa0023e565afbff41dfb76b361f85fd5e13f780d75ba18cc8", size = 3100, upload-time = "2021-08-09T16:47:55.807Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/a1/a5f4bebaa31d109003909809d88aeb0d4b201463a9ea29308d9e4f9e7655/qudida-0.0.4-py3-none-any.whl", hash = "sha256:4519714c40cd0f2e6c51e1735edae8f8b19f4efe1f33be13e9d644ca5f736dd6", size = 3478, upload-time = "2021-08-09T16:47:54.637Z" }, -] - [[package]] name = "rapidocr" version = "3.8.1" @@ -2661,139 +2258,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] -[[package]] -name = "scikit-image" -version = "0.25.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "imageio" }, - { name = "lazy-loader" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy" }, - { name = "tifffile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/97/3051c68b782ee3f1fb7f8f5bb7d535cf8cb92e8aae18fa9c1cdf7e15150d/scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17", size = 14003057, upload-time = "2025-02-18T18:04:30.395Z" }, - { url = "https://files.pythonhosted.org/packages/19/23/257fc696c562639826065514d551b7b9b969520bd902c3a8e2fcff5b9e17/scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0", size = 13180335, upload-time = "2025-02-18T18:04:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/ef/14/0c4a02cb27ca8b1e836886b9ec7c9149de03053650e9e2ed0625f248dd92/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173", size = 14144783, upload-time = "2025-02-18T18:04:36.594Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9b/9fb556463a34d9842491d72a421942c8baff4281025859c84fcdb5e7e602/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641", size = 14785376, upload-time = "2025-02-18T18:04:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/de/ec/b57c500ee85885df5f2188f8bb70398481393a69de44a00d6f1d055f103c/scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b", size = 12791698, upload-time = "2025-02-18T18:04:42.868Z" }, - { url = "https://files.pythonhosted.org/packages/35/8c/5df82881284459f6eec796a5ac2a0a304bb3384eec2e73f35cfdfcfbf20c/scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb", size = 13986000, upload-time = "2025-02-18T18:04:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e6/93bebe1abcdce9513ffec01d8af02528b4c41fb3c1e46336d70b9ed4ef0d/scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed", size = 13235893, upload-time = "2025-02-18T18:04:51.049Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/eda616e33f67129e5979a9eb33c710013caa3aa8a921991e6cc0b22cea33/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d", size = 14178389, upload-time = "2025-02-18T18:04:54.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824", size = 15003435, upload-time = "2025-02-18T18:04:57.586Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/49beb08ebccda3c21e871b607c1cb2f258c3fa0d2f609fed0a5ba741b92d/scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2", size = 12899474, upload-time = "2025-02-18T18:05:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841, upload-time = "2025-02-18T18:05:03.963Z" }, - { url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862, upload-time = "2025-02-18T18:05:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785, upload-time = "2025-02-18T18:05:10.69Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119, upload-time = "2025-02-18T18:05:13.871Z" }, - { url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116, upload-time = "2025-02-18T18:05:17.844Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801, upload-time = "2025-02-18T18:05:20.783Z" }, -] - -[[package]] -name = "scikit-learn" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/84/5f4af978fff619706b8961accac84780a6d298d82a8873446f72edb4ead0/scikit_learn-1.7.1.tar.gz", hash = "sha256:24b3f1e976a4665aa74ee0fcaac2b8fccc6ae77c8e07ab25da3ba6d3292b9802", size = 7190445, upload-time = "2025-07-18T08:01:54.5Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/bd/a23177930abd81b96daffa30ef9c54ddbf544d3226b8788ce4c3ef1067b4/scikit_learn-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90c8494ea23e24c0fb371afc474618c1019dc152ce4a10e4607e62196113851b", size = 9334838, upload-time = "2025-07-18T08:01:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a1/d3a7628630a711e2ac0d1a482910da174b629f44e7dd8cfcd6924a4ef81a/scikit_learn-1.7.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:bb870c0daf3bf3be145ec51df8ac84720d9972170786601039f024bf6d61a518", size = 8651241, upload-time = "2025-07-18T08:01:13.234Z" }, - { url = "https://files.pythonhosted.org/packages/26/92/85ec172418f39474c1cd0221d611345d4f433fc4ee2fc68e01f524ccc4e4/scikit_learn-1.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40daccd1b5623f39e8943ab39735cadf0bdce80e67cdca2adcb5426e987320a8", size = 9718677, upload-time = "2025-07-18T08:01:15.649Z" }, - { url = "https://files.pythonhosted.org/packages/df/ce/abdb1dcbb1d2b66168ec43b23ee0cee356b4cc4100ddee3943934ebf1480/scikit_learn-1.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30d1f413cfc0aa5a99132a554f1d80517563c34a9d3e7c118fde2d273c6fe0f7", size = 9511189, upload-time = "2025-07-18T08:01:18.013Z" }, - { url = "https://files.pythonhosted.org/packages/b2/3b/47b5eaee01ef2b5a80ba3f7f6ecf79587cb458690857d4777bfd77371c6f/scikit_learn-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:c711d652829a1805a95d7fe96654604a8f16eab5a9e9ad87b3e60173415cb650", size = 8914794, upload-time = "2025-07-18T08:01:20.357Z" }, - { url = "https://files.pythonhosted.org/packages/cb/16/57f176585b35ed865f51b04117947fe20f130f78940c6477b6d66279c9c2/scikit_learn-1.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3cee419b49b5bbae8796ecd690f97aa412ef1674410c23fc3257c6b8b85b8087", size = 9260431, upload-time = "2025-07-18T08:01:22.77Z" }, - { url = "https://files.pythonhosted.org/packages/67/4e/899317092f5efcab0e9bc929e3391341cec8fb0e816c4789686770024580/scikit_learn-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2fd8b8d35817b0d9ebf0b576f7d5ffbbabdb55536b0655a8aaae629d7ffd2e1f", size = 8637191, upload-time = "2025-07-18T08:01:24.731Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1b/998312db6d361ded1dd56b457ada371a8d8d77ca2195a7d18fd8a1736f21/scikit_learn-1.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:588410fa19a96a69763202f1d6b7b91d5d7a5d73be36e189bc6396bfb355bd87", size = 9486346, upload-time = "2025-07-18T08:01:26.713Z" }, - { url = "https://files.pythonhosted.org/packages/ad/09/a2aa0b4e644e5c4ede7006748f24e72863ba2ae71897fecfd832afea01b4/scikit_learn-1.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3142f0abe1ad1d1c31a2ae987621e41f6b578144a911ff4ac94781a583adad7", size = 9290988, upload-time = "2025-07-18T08:01:28.938Z" }, - { url = "https://files.pythonhosted.org/packages/15/fa/c61a787e35f05f17fc10523f567677ec4eeee5f95aa4798dbbbcd9625617/scikit_learn-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ddd9092c1bd469acab337d87930067c87eac6bd544f8d5027430983f1e1ae88", size = 8735568, upload-time = "2025-07-18T08:01:30.936Z" }, - { url = "https://files.pythonhosted.org/packages/52/f8/e0533303f318a0f37b88300d21f79b6ac067188d4824f1047a37214ab718/scikit_learn-1.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b7839687fa46d02e01035ad775982f2470be2668e13ddd151f0f55a5bf123bae", size = 9213143, upload-time = "2025-07-18T08:01:32.942Z" }, - { url = "https://files.pythonhosted.org/packages/71/f3/f1df377d1bdfc3e3e2adc9c119c238b182293e6740df4cbeac6de2cc3e23/scikit_learn-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a10f276639195a96c86aa572ee0698ad64ee939a7b042060b98bd1930c261d10", size = 8591977, upload-time = "2025-07-18T08:01:34.967Z" }, - { url = "https://files.pythonhosted.org/packages/99/72/c86a4cd867816350fe8dee13f30222340b9cd6b96173955819a5561810c5/scikit_learn-1.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13679981fdaebc10cc4c13c43344416a86fcbc61449cb3e6517e1df9d12c8309", size = 9436142, upload-time = "2025-07-18T08:01:37.397Z" }, - { url = "https://files.pythonhosted.org/packages/e8/66/277967b29bd297538dc7a6ecfb1a7dce751beabd0d7f7a2233be7a4f7832/scikit_learn-1.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f1262883c6a63f067a980a8cdd2d2e7f2513dddcef6a9eaada6416a7a7cbe43", size = 9282996, upload-time = "2025-07-18T08:01:39.721Z" }, - { url = "https://files.pythonhosted.org/packages/e2/47/9291cfa1db1dae9880420d1e07dbc7e8dd4a7cdbc42eaba22512e6bde958/scikit_learn-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca6d31fb10e04d50bfd2b50d66744729dbb512d4efd0223b864e2fdbfc4cee11", size = 8707418, upload-time = "2025-07-18T08:01:42.124Z" }, - { url = "https://files.pythonhosted.org/packages/61/95/45726819beccdaa34d3362ea9b2ff9f2b5d3b8bf721bd632675870308ceb/scikit_learn-1.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:781674d096303cfe3d351ae6963ff7c958db61cde3421cd490e3a5a58f2a94ae", size = 9561466, upload-time = "2025-07-18T08:01:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1c/6f4b3344805de783d20a51eb24d4c9ad4b11a7f75c1801e6ec6d777361fd/scikit_learn-1.7.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:10679f7f125fe7ecd5fad37dd1aa2daae7e3ad8df7f3eefa08901b8254b3e12c", size = 9040467, upload-time = "2025-07-18T08:01:46.671Z" }, - { url = "https://files.pythonhosted.org/packages/6f/80/abe18fe471af9f1d181904203d62697998b27d9b62124cd281d740ded2f9/scikit_learn-1.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f812729e38c8cb37f760dce71a9b83ccfb04f59b3dca7c6079dcdc60544fa9e", size = 9532052, upload-time = "2025-07-18T08:01:48.676Z" }, - { url = "https://files.pythonhosted.org/packages/14/82/b21aa1e0c4cee7e74864d3a5a721ab8fcae5ca55033cb6263dca297ed35b/scikit_learn-1.7.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88e1a20131cf741b84b89567e1717f27a2ced228e0f29103426102bc2e3b8ef7", size = 9361575, upload-time = "2025-07-18T08:01:50.639Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/f4777fcd5627dc6695fa6b92179d0edb7a3ac1b91bcd9a1c7f64fa7ade23/scikit_learn-1.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b1bd1d919210b6a10b7554b717c9000b5485aa95a1d0f177ae0d7ee8ec750da5", size = 9277310, upload-time = "2025-07-18T08:01:52.547Z" }, -] - -[[package]] -name = "scipy" -version = "1.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/4a/b927028464795439faec8eaf0b03b011005c487bb2d07409f28bf30879c4/scipy-1.16.1.tar.gz", hash = "sha256:44c76f9e8b6e8e488a586190ab38016e4ed2f8a038af7cd3defa903c0a2238b3", size = 30580861, upload-time = "2025-07-27T16:33:30.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/91/812adc6f74409b461e3a5fa97f4f74c769016919203138a3bf6fc24ba4c5/scipy-1.16.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:c033fa32bab91dc98ca59d0cf23bb876454e2bb02cbe592d5023138778f70030", size = 36552519, upload-time = "2025-07-27T16:26:29.658Z" }, - { url = "https://files.pythonhosted.org/packages/47/18/8e355edcf3b71418d9e9f9acd2708cc3a6c27e8f98fde0ac34b8a0b45407/scipy-1.16.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6e5c2f74e5df33479b5cd4e97a9104c511518fbd979aa9b8f6aec18b2e9ecae7", size = 28638010, upload-time = "2025-07-27T16:26:38.196Z" }, - { url = "https://files.pythonhosted.org/packages/d9/eb/e931853058607bdfbc11b86df19ae7a08686121c203483f62f1ecae5989c/scipy-1.16.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0a55ffe0ba0f59666e90951971a884d1ff6f4ec3275a48f472cfb64175570f77", size = 20909790, upload-time = "2025-07-27T16:26:43.93Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/be83a271d6e96750cd0be2e000f35ff18880a46f05ce8b5d3465dc0f7a2a/scipy-1.16.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f8a5d6cd147acecc2603fbd382fed6c46f474cccfcf69ea32582e033fb54dcfe", size = 23513352, upload-time = "2025-07-27T16:26:50.017Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bf/fe6eb47e74f762f933cca962db7f2c7183acfdc4483bd1c3813cfe83e538/scipy-1.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb18899127278058bcc09e7b9966d41a5a43740b5bb8dcba401bd983f82e885b", size = 33534643, upload-time = "2025-07-27T16:26:57.503Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ba/63f402e74875486b87ec6506a4f93f6d8a0d94d10467280f3d9d7837ce3a/scipy-1.16.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adccd93a2fa937a27aae826d33e3bfa5edf9aa672376a4852d23a7cd67a2e5b7", size = 35376776, upload-time = "2025-07-27T16:27:06.639Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b4/04eb9d39ec26a1b939689102da23d505ea16cdae3dbb18ffc53d1f831044/scipy-1.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:18aca1646a29ee9a0625a1be5637fa798d4d81fdf426481f06d69af828f16958", size = 35698906, upload-time = "2025-07-27T16:27:14.943Z" }, - { url = "https://files.pythonhosted.org/packages/04/d6/bb5468da53321baeb001f6e4e0d9049eadd175a4a497709939128556e3ec/scipy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d85495cef541729a70cdddbbf3e6b903421bc1af3e8e3a9a72a06751f33b7c39", size = 38129275, upload-time = "2025-07-27T16:27:23.873Z" }, - { url = "https://files.pythonhosted.org/packages/c4/94/994369978509f227cba7dfb9e623254d0d5559506fe994aef4bea3ed469c/scipy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:226652fca853008119c03a8ce71ffe1b3f6d2844cc1686e8f9806edafae68596", size = 38644572, upload-time = "2025-07-27T16:27:32.637Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d9/ec4864f5896232133f51382b54a08de91a9d1af7a76dfa372894026dfee2/scipy-1.16.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81b433bbeaf35728dad619afc002db9b189e45eebe2cd676effe1fb93fef2b9c", size = 36575194, upload-time = "2025-07-27T16:27:41.321Z" }, - { url = "https://files.pythonhosted.org/packages/5c/6d/40e81ecfb688e9d25d34a847dca361982a6addf8e31f0957b1a54fbfa994/scipy-1.16.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:886cc81fdb4c6903a3bb0464047c25a6d1016fef77bb97949817d0c0d79f9e04", size = 28594590, upload-time = "2025-07-27T16:27:49.204Z" }, - { url = "https://files.pythonhosted.org/packages/0e/37/9f65178edfcc629377ce9a64fc09baebea18c80a9e57ae09a52edf84880b/scipy-1.16.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:15240c3aac087a522b4eaedb09f0ad061753c5eebf1ea430859e5bf8640d5919", size = 20866458, upload-time = "2025-07-27T16:27:54.98Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7b/749a66766871ea4cb1d1ea10f27004db63023074c22abed51f22f09770e0/scipy-1.16.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f81a25805f3659b48126b5053d9e823d3215e4a63730b5e1671852a1705921", size = 23539318, upload-time = "2025-07-27T16:28:01.604Z" }, - { url = "https://files.pythonhosted.org/packages/c4/db/8d4afec60eb833a666434d4541a3151eedbf2494ea6d4d468cbe877f00cd/scipy-1.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c62eea7f607f122069b9bad3f99489ddca1a5173bef8a0c75555d7488b6f725", size = 33292899, upload-time = "2025-07-27T16:28:09.147Z" }, - { url = "https://files.pythonhosted.org/packages/51/1e/79023ca3bbb13a015d7d2757ecca3b81293c663694c35d6541b4dca53e98/scipy-1.16.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f965bbf3235b01c776115ab18f092a95aa74c271a52577bcb0563e85738fd618", size = 35162637, upload-time = "2025-07-27T16:28:17.535Z" }, - { url = "https://files.pythonhosted.org/packages/b6/49/0648665f9c29fdaca4c679182eb972935b3b4f5ace41d323c32352f29816/scipy-1.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f006e323874ffd0b0b816d8c6a8e7f9a73d55ab3b8c3f72b752b226d0e3ac83d", size = 35490507, upload-time = "2025-07-27T16:28:25.705Z" }, - { url = "https://files.pythonhosted.org/packages/62/8f/66cbb9d6bbb18d8c658f774904f42a92078707a7c71e5347e8bf2f52bb89/scipy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8fd15fc5085ab4cca74cb91fe0a4263b1f32e4420761ddae531ad60934c2119", size = 37923998, upload-time = "2025-07-27T16:28:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/14/c3/61f273ae550fbf1667675701112e380881905e28448c080b23b5a181df7c/scipy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7b8013c6c066609577d910d1a2a077021727af07b6fab0ee22c2f901f22352a", size = 38508060, upload-time = "2025-07-27T16:28:43.242Z" }, - { url = "https://files.pythonhosted.org/packages/93/0b/b5c99382b839854a71ca9482c684e3472badc62620287cbbdab499b75ce6/scipy-1.16.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5451606823a5e73dfa621a89948096c6528e2896e40b39248295d3a0138d594f", size = 36533717, upload-time = "2025-07-27T16:28:51.706Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e5/69ab2771062c91e23e07c12e7d5033a6b9b80b0903ee709c3c36b3eb520c/scipy-1.16.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:89728678c5ca5abd610aee148c199ac1afb16e19844401ca97d43dc548a354eb", size = 28570009, upload-time = "2025-07-27T16:28:57.017Z" }, - { url = "https://files.pythonhosted.org/packages/f4/69/bd75dbfdd3cf524f4d753484d723594aed62cfaac510123e91a6686d520b/scipy-1.16.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e756d688cb03fd07de0fffad475649b03cb89bee696c98ce508b17c11a03f95c", size = 20841942, upload-time = "2025-07-27T16:29:01.152Z" }, - { url = "https://files.pythonhosted.org/packages/ea/74/add181c87663f178ba7d6144b370243a87af8476664d5435e57d599e6874/scipy-1.16.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5aa2687b9935da3ed89c5dbed5234576589dd28d0bf7cd237501ccfbdf1ad608", size = 23498507, upload-time = "2025-07-27T16:29:05.202Z" }, - { url = "https://files.pythonhosted.org/packages/1d/74/ece2e582a0d9550cee33e2e416cc96737dce423a994d12bbe59716f47ff1/scipy-1.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0851f6a1e537fe9399f35986897e395a1aa61c574b178c0d456be5b1a0f5ca1f", size = 33286040, upload-time = "2025-07-27T16:29:10.201Z" }, - { url = "https://files.pythonhosted.org/packages/e4/82/08e4076df538fb56caa1d489588d880ec7c52d8273a606bb54d660528f7c/scipy-1.16.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fedc2cbd1baed37474b1924c331b97bdff611d762c196fac1a9b71e67b813b1b", size = 35176096, upload-time = "2025-07-27T16:29:17.091Z" }, - { url = "https://files.pythonhosted.org/packages/fa/79/cd710aab8c921375711a8321c6be696e705a120e3011a643efbbcdeeabcc/scipy-1.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2ef500e72f9623a6735769e4b93e9dcb158d40752cdbb077f305487e3e2d1f45", size = 35490328, upload-time = "2025-07-27T16:29:22.928Z" }, - { url = "https://files.pythonhosted.org/packages/71/73/e9cc3d35ee4526d784520d4494a3e1ca969b071fb5ae5910c036a375ceec/scipy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:978d8311674b05a8f7ff2ea6c6bce5d8b45a0cb09d4c5793e0318f448613ea65", size = 37939921, upload-time = "2025-07-27T16:29:29.108Z" }, - { url = "https://files.pythonhosted.org/packages/21/12/c0efd2941f01940119b5305c375ae5c0fcb7ec193f806bd8f158b73a1782/scipy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:81929ed0fa7a5713fcdd8b2e6f73697d3b4c4816d090dd34ff937c20fa90e8ab", size = 38479462, upload-time = "2025-07-27T16:30:24.078Z" }, - { url = "https://files.pythonhosted.org/packages/7a/19/c3d08b675260046a991040e1ea5d65f91f40c7df1045fffff412dcfc6765/scipy-1.16.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:bcc12db731858abda693cecdb3bdc9e6d4bd200213f49d224fe22df82687bdd6", size = 36938832, upload-time = "2025-07-27T16:29:35.057Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/ce53db652c033a414a5b34598dba6b95f3d38153a2417c5a3883da429029/scipy-1.16.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:744d977daa4becb9fc59135e75c069f8d301a87d64f88f1e602a9ecf51e77b27", size = 29093084, upload-time = "2025-07-27T16:29:40.201Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ae/7a10ff04a7dc15f9057d05b33737ade244e4bd195caa3f7cc04d77b9e214/scipy-1.16.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:dc54f76ac18073bcecffb98d93f03ed6b81a92ef91b5d3b135dcc81d55a724c7", size = 21365098, upload-time = "2025-07-27T16:29:44.295Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/029ff710959932ad3c2a98721b20b405f05f752f07344622fd61a47c5197/scipy-1.16.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:367d567ee9fc1e9e2047d31f39d9d6a7a04e0710c86e701e053f237d14a9b4f6", size = 23896858, upload-time = "2025-07-27T16:29:48.784Z" }, - { url = "https://files.pythonhosted.org/packages/71/13/d1ef77b6bd7898720e1f0b6b3743cb945f6c3cafa7718eaac8841035ab60/scipy-1.16.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4cf5785e44e19dcd32a0e4807555e1e9a9b8d475c6afff3d21c3c543a6aa84f4", size = 33438311, upload-time = "2025-07-27T16:29:54.164Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e0/e64a6821ffbb00b4c5b05169f1c1fddb4800e9307efe3db3788995a82a2c/scipy-1.16.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3d0b80fb26d3e13a794c71d4b837e2a589d839fd574a6bbb4ee1288c213ad4a3", size = 35279542, upload-time = "2025-07-27T16:30:00.249Z" }, - { url = "https://files.pythonhosted.org/packages/57/59/0dc3c8b43e118f1e4ee2b798dcc96ac21bb20014e5f1f7a8e85cc0653bdb/scipy-1.16.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8503517c44c18d1030d666cb70aaac1cc8913608816e06742498833b128488b7", size = 35667665, upload-time = "2025-07-27T16:30:05.916Z" }, - { url = "https://files.pythonhosted.org/packages/45/5f/844ee26e34e2f3f9f8febb9343748e72daeaec64fe0c70e9bf1ff84ec955/scipy-1.16.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:30cc4bb81c41831ecfd6dc450baf48ffd80ef5aed0f5cf3ea775740e80f16ecc", size = 38045210, upload-time = "2025-07-27T16:30:11.655Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d7/210f2b45290f444f1de64bc7353aa598ece9f0e90c384b4a156f9b1a5063/scipy-1.16.1-cp313-cp313t-win_amd64.whl", hash = "sha256:c24fa02f7ed23ae514460a22c57eca8f530dbfa50b1cfdbf4f37c05b5309cc39", size = 38593661, upload-time = "2025-07-27T16:30:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/81/ea/84d481a5237ed223bd3d32d6e82d7a6a96e34756492666c260cef16011d1/scipy-1.16.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:796a5a9ad36fa3a782375db8f4241ab02a091308eb079746bc0f874c9b998318", size = 36525921, upload-time = "2025-07-27T16:30:30.081Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9f/d9edbdeff9f3a664807ae3aea383e10afaa247e8e6255e6d2aa4515e8863/scipy-1.16.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:3ea0733a2ff73fd6fdc5fecca54ee9b459f4d74f00b99aced7d9a3adb43fb1cc", size = 28564152, upload-time = "2025-07-27T16:30:35.336Z" }, - { url = "https://files.pythonhosted.org/packages/3b/95/8125bcb1fe04bc267d103e76516243e8d5e11229e6b306bda1024a5423d1/scipy-1.16.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:85764fb15a2ad994e708258bb4ed8290d1305c62a4e1ef07c414356a24fcfbf8", size = 20836028, upload-time = "2025-07-27T16:30:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/77/9c/bf92e215701fc70bbcd3d14d86337cf56a9b912a804b9c776a269524a9e9/scipy-1.16.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ca66d980469cb623b1759bdd6e9fd97d4e33a9fad5b33771ced24d0cb24df67e", size = 23489666, upload-time = "2025-07-27T16:30:43.663Z" }, - { url = "https://files.pythonhosted.org/packages/5e/00/5e941d397d9adac41b02839011594620d54d99488d1be5be755c00cde9ee/scipy-1.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7cc1ffcc230f568549fc56670bcf3df1884c30bd652c5da8138199c8c76dae0", size = 33358318, upload-time = "2025-07-27T16:30:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/0e/87/8db3aa10dde6e3e8e7eb0133f24baa011377d543f5b19c71469cf2648026/scipy-1.16.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ddfb1e8d0b540cb4ee9c53fc3dea3186f97711248fb94b4142a1b27178d8b4b", size = 35185724, upload-time = "2025-07-27T16:30:54.26Z" }, - { url = "https://files.pythonhosted.org/packages/89/b4/6ab9ae443216807622bcff02690262d8184078ea467efee2f8c93288a3b1/scipy-1.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4dc0e7be79e95d8ba3435d193e0d8ce372f47f774cffd882f88ea4e1e1ddc731", size = 35554335, upload-time = "2025-07-27T16:30:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/9c/9a/d0e9dc03c5269a1afb60661118296a32ed5d2c24298af61b676c11e05e56/scipy-1.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f23634f9e5adb51b2a77766dac217063e764337fbc816aa8ad9aaebcd4397fd3", size = 37960310, upload-time = "2025-07-27T16:31:06.151Z" }, - { url = "https://files.pythonhosted.org/packages/5e/00/c8f3130a50521a7977874817ca89e0599b1b4ee8e938bad8ae798a0e1f0d/scipy-1.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:57d75524cb1c5a374958a2eae3d84e1929bb971204cc9d52213fb8589183fc19", size = 39319239, upload-time = "2025-07-27T16:31:59.942Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f2/1ca3eda54c3a7e4c92f6acef7db7b3a057deb135540d23aa6343ef8ad333/scipy-1.16.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:d8da7c3dd67bcd93f15618938f43ed0995982eb38973023d46d4646c4283ad65", size = 36939460, upload-time = "2025-07-27T16:31:11.865Z" }, - { url = "https://files.pythonhosted.org/packages/80/30/98c2840b293a132400c0940bb9e140171dcb8189588619048f42b2ce7b4f/scipy-1.16.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:cc1d2f2fd48ba1e0620554fe5bc44d3e8f5d4185c8c109c7fbdf5af2792cfad2", size = 29093322, upload-time = "2025-07-27T16:31:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/c1/e6/1e6e006e850622cf2a039b62d1a6ddc4497d4851e58b68008526f04a9a00/scipy-1.16.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:21a611ced9275cb861bacadbada0b8c0623bc00b05b09eb97f23b370fc2ae56d", size = 21365329, upload-time = "2025-07-27T16:31:21.188Z" }, - { url = "https://files.pythonhosted.org/packages/8e/02/72a5aa5b820589dda9a25e329ca752842bfbbaf635e36bc7065a9b42216e/scipy-1.16.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dfbb25dffc4c3dd9371d8ab456ca81beeaf6f9e1c2119f179392f0dc1ab7695", size = 23897544, upload-time = "2025-07-27T16:31:25.408Z" }, - { url = "https://files.pythonhosted.org/packages/2b/dc/7122d806a6f9eb8a33532982234bed91f90272e990f414f2830cfe656e0b/scipy-1.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0ebb7204f063fad87fc0a0e4ff4a2ff40b2a226e4ba1b7e34bf4b79bf97cd86", size = 33442112, upload-time = "2025-07-27T16:31:30.62Z" }, - { url = "https://files.pythonhosted.org/packages/24/39/e383af23564daa1021a5b3afbe0d8d6a68ec639b943661841f44ac92de85/scipy-1.16.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f1b9e5962656f2734c2b285a8745358ecb4e4efbadd00208c80a389227ec61ff", size = 35286594, upload-time = "2025-07-27T16:31:36.112Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/1a0b0aff40c3056d955f38b0df5d178350c3d74734ec54f9c68d23910be5/scipy-1.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e1a106f8c023d57a2a903e771228bf5c5b27b5d692088f457acacd3b54511e4", size = 35665080, upload-time = "2025-07-27T16:31:42.025Z" }, - { url = "https://files.pythonhosted.org/packages/64/df/ce88803e9ed6e27fe9b9abefa157cf2c80e4fa527cf17ee14be41f790ad4/scipy-1.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:709559a1db68a9abc3b2c8672c4badf1614f3b440b3ab326d86a5c0491eafae3", size = 38050306, upload-time = "2025-07-27T16:31:48.109Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6c/a76329897a7cae4937d403e623aa6aaea616a0bb5b36588f0b9d1c9a3739/scipy-1.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c0c804d60492a0aad7f5b2bb1862f4548b990049e27e828391ff2bf6f7199998", size = 39427705, upload-time = "2025-07-27T16:31:53.96Z" }, -] - [[package]] name = "setuptools" version = "80.9.0" @@ -2910,27 +2374,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/05/e6600db80270777c4a64238a98d442f0fd07cc8915be2a1c16da7f2b9e74/sympy-1.12-py3-none-any.whl", hash = "sha256:c3588cd4295d0c0f603d0f2ae780587e64e2efeedb3521e46b9bb1d08d184fa5", size = 5742435, upload-time = "2023-05-10T18:22:14.76Z" }, ] -[[package]] -name = "threadpoolctl" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/8a/c05f7831beb32aff70f808766224f11c650f7edfd49b27a8fc6666107006/threadpoolctl-3.2.0.tar.gz", hash = "sha256:c96a0ba3bdddeaca37dc4cc7344aafad41cdb8c313f74fdfe387a867bba93355", size = 36266, upload-time = "2023-07-13T14:53:53.299Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/12/fd4dea011af9d69e1cad05c75f3f7202cdcbeac9b712eea58ca779a72865/threadpoolctl-3.2.0-py3-none-any.whl", hash = "sha256:2b7818516e423bdaebb97c723f86a7c6b0a83d3f3b0970328d66f4d9104dc032", size = 15539, upload-time = "2023-07-13T14:53:39.336Z" }, -] - -[[package]] -name = "tifffile" -version = "2023.12.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/6c0eadea1ccfcda27e6cce400c366098b5b082138a073f4252fe399f4148/tifffile-2023.12.9.tar.gz", hash = "sha256:9dd1da91180a6453018a241ff219e1905f169384355cd89c9ef4034c1b46cdb8", size = 353467, upload-time = "2023-12-09T20:46:29.203Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/a4/569fc717831969cf48bced350bdaf070cdeab06918d179429899e144358d/tifffile-2023.12.9-py3-none-any.whl", hash = "sha256:9b066e4b1a900891ea42ffd33dab8ba34c537935618b9893ddef42d7d422692f", size = 223627, upload-time = "2023-12-09T20:46:26.569Z" }, -] - [[package]] name = "tokenizers" version = "0.23.1" @@ -3211,15 +2654,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/a5/7aba9435beb863c2490bae3173a45f42044ac7a48155d3dd42ab49cfae45/watchfiles-0.21.0-cp312-none-win_arm64.whl", hash = "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6", size = 268026, upload-time = "2023-10-13T13:05:38.591Z" }, ] -[[package]] -name = "wcwidth" -version = "0.2.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, -] - [[package]] name = "websocket-client" version = "1.8.0" diff --git a/mise.lock b/mise.lock index a4b395500b..836712e0fd 100644 --- a/mise.lock +++ b/mise.lock @@ -104,17 +104,17 @@ version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" [tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" +asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"] -checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"] +checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879" -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"] -checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"] +checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879" [[tools."github:jellyfin/jellyfin-ffmpeg"]] version = "7.1.3-6" @@ -133,17 +133,17 @@ version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" [tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" +asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"] -checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879" +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"] +checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"] -checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879" +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"] +checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" [[tools."github:webassembly/binaryen"]] version = "version_124" @@ -291,43 +291,43 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.5/tofu_1.12. url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/484602547" [[tools.pnpm]] -version = "11.17.0" +version = "11.20.0" backend = "aqua:pnpm/pnpm" [tools.pnpm."platforms.linux-arm64"] -checksum = "sha256:730d17de742a3efbb020ba91d7acfc0456c6ba6ad1cd8eb49f4c229fe9f504d3" -url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-linux-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422123" +checksum = "sha256:f00fc2041bb41742b7943bf2bb24183ad20320e8384824a8031eb94edf2f57a5" +url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142378" provenance = "github-attestations" [tools.pnpm."platforms.linux-arm64-musl"] -checksum = "sha256:cc072c0e7bdd290f52fb9afdcea778ede69f96f0f4dae63623163fc11d4beec3" -url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-linux-arm64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422121" +checksum = "sha256:2a21235d3f0fbfbcb9e530b9a98f2c64dcee95d1d5d1c2d04428255c7b85f32c" +url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142372" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64"] -checksum = "sha256:bdb1db01bf0f757495405a59a09c5c287f315889dc98d3b14bc374b9fe43a0bf" -url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-linux-x64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422122" +checksum = "sha256:b4ad6ad2b21db2f8cd50af416c3aa148ba704c31c84893f465a770a01c2c4572" +url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142376" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-musl"] -checksum = "sha256:db2e4eeecab336bd41bbbc7a39626b166f1a15bb189efcf6a1dafdf159f23fe7" -url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-linux-x64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422116" +checksum = "sha256:db046881c027f1a2d69e9a530a21a62c2bced4ba8ff437e7300426ae56951fe4" +url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142377" provenance = "github-attestations" [tools.pnpm."platforms.macos-arm64"] -checksum = "sha256:1e9a35d76f9d382af365d95c64f3abf7815e24350653f5bb1a37e4546265bc2b" -url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-darwin-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422117" +checksum = "sha256:4bc97fea72e5c92eec1fefc8d410c35d01e0d0d52f3160c59f38c32db58b5cd2" +url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142373" provenance = "github-attestations" [tools.pnpm."platforms.windows-x64"] -checksum = "sha256:3025c174a6dcffec14a071a5c854cb0c350a09a8c7c2857a75f75cc7b29b939c" -url = "https://github.com/pnpm/pnpm/releases/download/v11.17.0/pnpm-win32-x64.zip" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/487422118" +checksum = "sha256:ea2528bdc3d96a1ff3c35587dc48ca692b39d77f08f26df4adeaaa9eb427024e" +url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-win32-x64.zip" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142371" provenance = "github-attestations" [[tools.terragrunt]] diff --git a/mise.toml b/mise.toml index ad7fdf5a90..0039dbac2d 100644 --- a/mise.toml +++ b/mise.toml @@ -16,7 +16,7 @@ config_roots = [ [tools] node = "24.15.0" -pnpm = "11.17.0" +pnpm = "11.20.0" terragrunt = "1.1.1" opentofu = "1.12.5" "npm:@openapitools/openapi-generator-cli" = "2.40.1" diff --git a/mobile/.gitignore b/mobile/.gitignore index 6730e3c46a..c426d46ec3 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -32,6 +32,7 @@ .pub/ /build/ lib/**/*.drift.dart +lib/**/*.freezed.dart lib/routing/router.gr.dart test/drift/main/generated/ diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index e828760789..065b3d56d8 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -26,52 +26,59 @@ linter: # producing the lint. rules: - # Formatting - avoid_print: true - require_trailing_commas: true - unrelated_type_equality_checks: true - prefer_const_constructors: true - always_use_package_imports: true - always_put_control_body_on_new_line: true + # Correctness + always_declare_return_types: true + avoid_type_to_string: true + avoid_unused_constructor_parameters: true + avoid_void_async: true + cancel_subscriptions: true + cast_nullable_to_non_nullable: true + close_sinks: true + collection_methods_unrelated_type: true + deprecated_consistency: true + discarded_futures: true + no_adjacent_strings_in_list: true + no_self_assignments: true + noop_primitive_operations: true + only_throw_errors: true + parameter_assignments: true + throw_in_finally: true + tighten_type_of_initializing_formals: true + unawaited_futures: true + unnecessary_ignore: true unnecessary_null_checks: true - unnecessary_parenthesis: true - prefer_final_locals: true + unnecessary_statements: true + unrelated_type_equality_checks: true + + # Performance + avoid_slow_async_io: true + prefer_const_constructors: true prefer_const_declarations: true prefer_const_literals_to_create_immutables: true - use_super_parameters: true - directives_ordering: true - no_leading_underscores_for_local_identifiers: true - always_declare_return_types: true - avoid_void_async: true - noop_primitive_operations: true - use_named_constants: true - combinators_ordering: true - avoid_multiple_declarations_per_line: true - unnecessary_breaks: true - - # Correctness - no_adjacent_strings_in_list: true - cancel_subscriptions: true - close_sinks: true - unawaited_futures: true - discarded_futures: true - no_self_assignments: true - throw_in_finally: true - collection_methods_unrelated_type: true - cast_nullable_to_non_nullable: true - - # Known issues - avoid_slow_async_io: true - avoid_type_to_string: true # Flutter specific - use_build_context_synchronously: true + avoid_unnecessary_containers: true sized_box_for_whitespace: true + use_build_context_synchronously: true use_colored_box: true use_decorated_box: true - avoid_unnecessary_containers: true use_full_hex_values_for_flutter_colors: true + # Style + always_put_control_body_on_new_line: true + always_use_package_imports: true + avoid_multiple_declarations_per_line: true + avoid_print: true + combinators_ordering: true + directives_ordering: true + no_leading_underscores_for_local_identifiers: true + prefer_final_locals: true + require_trailing_commas: true + unnecessary_breaks: true + unnecessary_parenthesis: true + use_named_constants: true + use_super_parameters: true + # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options analyzer: @@ -97,6 +104,18 @@ dart_code_metrics: exclude-paths: - 'lib/utils/debug_print.dart' severity: perf + - name: tr + description: 'Use context.t. or StaticTranslations.instance. instead of tr().' + - type: String + entries: + - name: tr + description: 'Use context.t. or StaticTranslations.instance. instead of String.tr().' + - name: plural + description: 'Use the generated plural accessor instead of String.plural().' + - type: Text + entries: + - name: tr + description: 'Use Text(context.t.) instead of Text().tr().' # All rules from "recommended" preset # Show potential errors # - avoid-cascade-after-if-null @@ -110,3 +129,17 @@ dart_code_metrics: - prefer-const-border-radius - prefer-declaring-const-constructor - prefer-switch-expression + + # Riverpod + - avoid-calling-notifier-members-inside-build + - avoid-notifier-constructors + - avoid-nullable-async-value-pattern + - avoid-public-notifier-properties + - avoid-ref-inside-state-dispose + - avoid-ref-read-inside-build + - avoid-ref-watch-outside-build + - avoid-unnecessary-consumer-widgets + - dispose-provided-instances + - prefer-immutable-provider-arguments + - use-ref-and-state-synchronously + - use-ref-read-synchronously diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 1b8d2a97fb..5f4de8284a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -152,12 +152,6 @@ This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> - - - ?>) -> Unit ) +/** Set [exactSize] to decode at the smallest size that covers [target] without upscaling. */ @RequiresApi(Build.VERSION_CODES.Q) -inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap { +inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0), exactSize: Boolean = false): Bitmap { return ImageDecoder.decodeBitmap(this) { decoder, info, _ -> if (target.width > 0 && target.height > 0) { - val sample = max(1, min(info.size.width / target.width, info.size.height / target.height)) - decoder.setTargetSampleSize(sample) + if (exactSize) { + val fillScale = max( + target.width.toDouble() / info.size.width, + target.height.toDouble() / info.size.height + ) + val scale = min(1.0, fillScale) + if (scale < 1) { + val width = ceil(info.size.width * scale).toInt() + val height = ceil(info.size.height * scale).toInt() + if (width > 0 && height > 0) { + decoder.setTargetSize(width, height) + } + } + } else { + val sample = max(1, min(info.size.width / target.width, info.size.height / target.height)) + decoder.setTargetSampleSize(sample) + } } decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 1623c9cb09..cdc82c871c 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -5,6 +5,7 @@ import android.graphics.ImageDecoder import android.os.Build import android.os.CancellationSignal import android.os.OperationCanceledException +import android.util.Size import androidx.exifinterface.media.ExifInterface import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.NativeBuffer @@ -79,6 +80,8 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { url: String, requestId: Long, preferEncoded: Boolean, + width: Long?, + height: Long?, callback: (Result?>) -> Unit ) { val signal = CancellationSignal() @@ -102,13 +105,21 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { decodeExecutor.execute { val res = if (signal.isCanceled) null else try { - val bitmap = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset)).decodeBitmap() // The embedded preview a raw decodes to has no orientation, so read the container's. val orientation = if (isRawMime(contentType)) { readRawOrientation(NativeBuffer.wrap(buffer.pointer, buffer.offset), buffer.offset) } else { ExifInterface.ORIENTATION_NORMAL } + val target = when (orientation) { + ExifInterface.ORIENTATION_TRANSPOSE, + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_TRANSVERSE, + ExifInterface.ORIENTATION_ROTATE_270 -> Size(height?.toInt() ?: 0, width?.toInt() ?: 0) + else -> Size(width?.toInt() ?: 0, height?.toInt() ?: 0) + } + val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset)) + val bitmap = source.decodeBitmap(target, exactSize = true) if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) { bitmap.toNativeBuffer() } else { diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 18b771a613..25a849ad3a 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.content.ContentUris import android.content.Context import android.database.Cursor -import androidx.exifinterface.media.ExifInterface import android.os.Build import android.os.Bundle import android.os.ext.SdkExtensions @@ -30,6 +29,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import java.io.File +import java.io.IOException +import java.io.InputStream import java.security.MessageDigest import kotlin.coroutines.cancellation.CancellationException @@ -377,7 +378,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa )?.use { cursor -> cursor.count.toLong() } ?: 0L - fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result>) -> Unit) { + fun getAssetsForAlbum( + albumId: String, + updatedTimeCond: Long?, + callback: (Result>) -> Unit + ) { runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) } } @@ -419,7 +424,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa }.awaitAll() completeWhenActive(callback, Result.success(results)) - } catch (e: CancellationException) { + } catch (_: CancellationException) { completeWhenActive( callback, Result.failure( FlutterError( @@ -437,23 +442,20 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa private suspend fun hashAsset(assetId: String): HashResult { return try { - val assetUri = ContentUris.withAppendedId( - MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), - assetId.toLong() - ) - val digest = MessageDigest.getInstance("SHA-1") - ctx.contentResolver.openInputStream(assetUri)?.use { inputStream -> - var bytesRead: Int + openOriginalStream(assetId).use { inputStream -> val buffer = ByteArray(HASH_BUFFER_SIZE) - while (inputStream.read(buffer).also { bytesRead = it } > 0) { + while (true) { + val bytesRead = inputStream.read(buffer) + if (bytesRead == -1) break currentCoroutineContext().ensureActive() digest.update(buffer, 0, bytesRead) } - } ?: return HashResult(assetId, "Cannot open input stream for asset", null) + } - val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP) - HashResult(assetId, null, hashString) + HashResult(assetId, null, Base64.encodeToString(digest.digest(), Base64.NO_WRAP)) + } catch (e: CancellationException) { + throw e } catch (e: SecurityException) { HashResult(assetId, "Permission denied accessing asset: ${e.message}", null) } catch (e: Exception) { @@ -461,6 +463,37 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa } } + private fun openOriginalStream(assetId: String): InputStream { + val id = assetId.toLong() + val collection = when (getMediaType(id)) { + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + else -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val uri = ContentUris.withAppendedId(collection, id) + val original = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.setRequireOriginal(uri) + } else { + uri + } + + return ctx.contentResolver.openInputStream(original) + ?: throw IOException("Cannot open original stream for asset $assetId") + } + + private fun getMediaType(id: Long): Int = + getCursor( + MediaStore.VOLUME_EXTERNAL, + "${MediaStore.MediaColumns._ID} = ?", + arrayOf(id.toString()), + arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE) + )?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)) + } else { + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE + } + } ?: MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE + fun cancelHashing() { hashTask?.cancel() hashTask = null @@ -476,8 +509,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa syncJob = CoroutineScope(Dispatchers.IO).launch { try { completeWhenActive(callback, Result.success(work())) - } catch (e: CancellationException) { - completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null))) + } catch (_: CancellationException) { + completeWhenActive( + callback, + Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)) + ) } catch (e: Exception) { completeWhenActive(callback, Result.failure(e)) } diff --git a/mobile/bin/generate_keys.dart b/mobile/bin/generate_keys.dart index 1ce643be12..160f2c5b5c 100644 --- a/mobile/bin/generate_keys.dart +++ b/mobile/bin/generate_keys.dart @@ -97,6 +97,15 @@ const _kIntParamNames = [ 'quantity', ]; +const _kParamTypeOverrides = { + 'advanced_settings_clear_image_cache_success.size': 'String', + 'backup_controller_page_storage_format.total': 'String', + 'backup_controller_page_storage_format.used': 'String', + 'cleanup_found_assets_with_size.size': 'String', +}; + +final _usedParamTypeOverrides = {}; + void main() async { final sourceFile = File('../i18n/en.json'); if (!await sourceFile.exists()) { @@ -143,6 +152,11 @@ class TranslationParam { Future _generateTranslations(Map translations, File output) async { final root = _buildTranslationTree('', translations); + final staleOverrides = _kParamTypeOverrides.keys.toSet().difference(_usedParamTypeOverrides); + if (staleOverrides.isNotEmpty) { + throw StateError('_kParamTypeOverrides has entries matching no plain {placeholder}: ${staleOverrides.join(', ')}'); + } + final buffer = StringBuffer(''' // DO NOT EDIT. This is code generated via generate_keys.dart @@ -194,21 +208,22 @@ class Translations extends _BaseTranslations { await output.writeAsString(buffer.toString()); } -TranslationNode _buildTranslationTree(String key, dynamic value) { +TranslationNode _buildTranslationTree(String key, dynamic value, [String parentKey = '']) { + final fullKey = parentKey.isEmpty ? key : '$parentKey.$key'; if (value is Map) { final children = {}; for (final entry in value.entries) { - children[entry.key] = _buildTranslationTree(entry.key, entry.value); + children[entry.key] = _buildTranslationTree(entry.key, entry.value, fullKey); } return TranslationNode(key: key, children: children); } else { final stringValue = value.toString(); - final params = _extractParams(stringValue); + final params = _extractParams(stringValue, fullKey); return TranslationNode(key: key, value: stringValue, params: params); } } -List _extractParams(String value) { +List _extractParams(String value, String fullKey) { final params = {}; final icuRegex = RegExp(r'\{(\w+),\s*(plural|select|number|date|time)([^}]*(?:\{[^}]*\}[^}]*)*)\}'); @@ -264,8 +279,11 @@ List _extractParams(String value) { continue; } - String type; - if (_kIntParamNames.contains(name.toLowerCase())) { + final String type; + if (_kParamTypeOverrides['$fullKey.$name'] case final override?) { + type = override; + _usedParamTypeOverrides.add('$fullKey.$name'); + } else if (_kIntParamNames.contains(name.toLowerCase())) { type = 'int'; } else { type = 'Object'; diff --git a/mobile/build.yaml b/mobile/build.yaml index cb718d9d3e..32cd96c5d3 100644 --- a/mobile/build.yaml +++ b/mobile/build.yaml @@ -18,8 +18,7 @@ targets: generate_for: &drift_generate_for - lib/infrastructure/entities/*.dart - lib/infrastructure/entities/*.drift - - lib/infrastructure/repositories/db.repository.dart - - lib/infrastructure/repositories/logger_db.repository.dart + - lib/infrastructure/repositories/*.repository.dart drift_dev:modular: enabled: true options: *drift_options diff --git a/mobile/integration_test/failed_sync_resume_e2e_test.dart b/mobile/integration_test/failed_sync_resume_e2e_test.dart new file mode 100644 index 0000000000..255074df93 --- /dev/null +++ b/mobile/integration_test/failed_sync_resume_e2e_test.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; +import 'package:immich_mobile/domain/utils/background_sync.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/main.dart' as app; +import 'package:immich_mobile/platform/background_worker_api.g.dart'; +import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; +import 'package:immich_mobile/wm_executor.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'test_utils/fake_immich_server.dart'; + +// Issue #28082 end-to-end: a resume after a sync froze mid-flight starts a fresh sync. +// Kept in its own file on purpose: run after the failed_sync_resume tests in one process, +// the resume sync starves past its window - a cross-test interaction that survived pool, +// sqlite and bg-worker audits unnamed. One file per process is device-proven green. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + + late Drift drift; + late FakeImmichServer server; + + // main()'s formula with a higher floor: a wedged frozen worker plus a full local + // sync must not starve the fresh resume sync out of its 25s window on 4 cores. + final poolSize = max(Platform.numberOfProcessors - 1, 8); + + setUpAll(() async { + await app.initApp(); + (drift, _) = await Bootstrap.initDomain(); + // A background-worker schedule persisted by real app use on this device can + // launch a second engine mid-file (own isolate pool + full sync) and starve + // these tests on a small device. Unregister it for the whole run. + await BackgroundWorkerFgService(BackgroundWorkerFgHostApi()).disable(); + }); + + setUp(() async { + // A task completing while dispose tears the pool down re-warms it (_schedule + // on the cleared pool re-creates workers), and init on a warm pool is silently + // ignored - poolSize would never apply. Reset first, then verify it took. + await workerManagerPatch.dispose(); + await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: poolSize); + expect( + workerManagerPatch.pool.length, + poolSize, + reason: 'init was ignored: a straggler from the previous test re-warmed the pool', + ); + server = await FakeImmichServer.start(); + await ApiService().resolveAndSetEndpoint(server.endpoint); + await drift.delete(drift.userEntity).go(); + }); + + tearDown(() async { + // Close the server first so any held-open sync stream ends and its isolate unwinds, + // then drain the pool - otherwise dispose waits on the frozen read. + await server.close(); + await workerManagerPatch.dispose(); + }); + + testWidgets('a resume after a sync froze mid-flight starts a fresh sync', (tester) async { + final manager = BackgroundSyncManager(); + // Not disposed on purpose: driftOverride closes the drift on dispose, and that + // drift belongs to setUpAll. The container only holds the shared drift and a + // fire-and-forget resume; the frozen isolates + server are drained by tearDown. + final container = ProviderContainer( + overrides: [driftProvider.overrideWith(driftOverride(drift)), backgroundSyncProvider.overrideWithValue(manager)], + ); + + // A first sync opens /sync/stream and never finishes - the frozen state a + // suspended sync isolate is left in. Holding the stream open keeps + // _syncTask non-null, exactly as it is across an iOS process suspension. + unawaited(manager.syncRemote()); + await server + .streamOpenedNth(1) + .timeout(const Duration(seconds: 30), onTimeout: () => fail('first sync isolate never opened /sync/stream')); + + // The lifecycle then goes background -> foreground. handleAppResume runs the + // resume sync exactly once. On the buggy build it hangs on the stale task's + // future, so it is not awaited here. + final notifier = container.read(appStateProvider.notifier); + await notifier.handleAppPause(); + unawaited(notifier.handleAppResume()); + + await server + .streamOpenedNth(2) + .timeout( + const Duration(seconds: 25), + onTimeout: () => fail('resume did not start a fresh remote sync - the stale frozen sync blocked it (#28082)'), + ); + expect(server.streamOpenCount, greaterThanOrEqualTo(2)); + }); +} diff --git a/mobile/integration_test/failed_sync_resume_test.dart b/mobile/integration_test/failed_sync_resume_test.dart new file mode 100644 index 0000000000..c23319dffe --- /dev/null +++ b/mobile/integration_test/failed_sync_resume_test.dart @@ -0,0 +1,133 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; +import 'package:immich_mobile/domain/utils/background_sync.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/main.dart' as app; +import 'package:immich_mobile/platform/background_worker_api.g.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; +import 'package:immich_mobile/wm_executor.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'test_utils/fake_immich_server.dart'; + +// Issue #28082: a remote sync in-flight when the app is backgrounded stays referenced +// but frozen across the suspension. On resume the app drops the stale task +// (cancelResumeSyncs) and starts a fresh sync. +// +// Device/emulator tests: real worker isolates + a real drift db + a loopback fake server +// (same pattern as background_sync_teardown_test). The mobile integration-test CI job is +// disabled in test.yml, so like Mert's teardown test this is a local/on-device guard. +// +// The end-to-end resume test lives in failed_sync_resume_e2e_test.dart - one process +// per file keeps it clear of the cross-test interaction described there. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + + late Drift drift; + late FakeImmichServer server; + + // main()'s formula with a higher floor: a wedged frozen worker plus a full local + // sync must not starve the fresh resume sync out of its 25s window on 4 cores. + final poolSize = max(Platform.numberOfProcessors - 1, 8); + + setUpAll(() async { + await app.initApp(); + (drift, _) = await Bootstrap.initDomain(); + // A background-worker schedule persisted by real app use on this device can + // launch a second engine mid-file (own isolate pool + full sync) and starve + // these tests on a small device. Unregister it for the whole run. + await BackgroundWorkerFgService(BackgroundWorkerFgHostApi()).disable(); + }); + + setUp(() async { + // A task completing while dispose tears the pool down re-warms it (_schedule + // on the cleared pool re-creates workers), and init on a warm pool is silently + // ignored - poolSize would never apply. Reset first, then verify it took. + await workerManagerPatch.dispose(); + await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: poolSize); + expect( + workerManagerPatch.pool.length, + poolSize, + reason: 'init was ignored: a straggler from the previous test re-warmed the pool', + ); + server = await FakeImmichServer.start(); + await ApiService().resolveAndSetEndpoint(server.endpoint); + await drift.delete(drift.userEntity).go(); + }); + + tearDown(() async { + // Close the server first so any held-open sync stream ends and its isolate unwinds, + // then drain the pool - otherwise dispose waits on the frozen read. + await server.close(); + await workerManagerPatch.dispose(); + }); + + // Self-contained (bare manager, no fire-and-forget resume), so it runs first: its + // frozen syncs are fully drained by tearDown. + testWidgets('a cancelled sync task does not clear the slot of the fresh task that superseded it', (tester) async { + final manager = BackgroundSyncManager(); + + // First sync opens /sync/stream and is held open - the frozen suspended state. + unawaited(manager.syncRemote()); + await server + .streamOpenedNth(1) + .timeout(const Duration(seconds: 30), onTimeout: () => fail('first sync isolate never opened /sync/stream')); + + // Resume drops the stale task then immediately starts a fresh one, exactly as + // _handleBetaTimelineResume does. cancelResumeSyncs cancels the first task; its + // completion chain then fires and must NOT null the fresh task's slot. + unawaited(manager.cancelResumeSyncs()); + unawaited(manager.syncRemote()); + await server + .streamOpenedNth(2) + .timeout(const Duration(seconds: 30), onTimeout: () => fail('fresh sync isolate never opened /sync/stream')); + + // A third sync stream only opens if the cancelled task cleared the fresh slot, + // letting the dedupe guard start a redundant sync. + var thirdOpened = false; + unawaited(server.streamOpenedNth(3).then((_) => thirdOpened = true)); + + // Let the cancelled task's completion chain settle, then ask to sync again. + await Future.delayed(const Duration(milliseconds: 200)); + unawaited(manager.syncRemote()); + await Future.delayed(const Duration(seconds: 3)); + + expect( + thirdOpened, + isFalse, + reason: 'the cancelled task cleared the fresh task slot, so a redundant third sync started (#28082 clobber)', + ); + expect(server.streamOpenCount, 2); + }); + + // The false-error-flash fix: a task cancelled by cancelResumeSyncs completes with a + // CanceledError, which is not a sync failure and must not reach onRemoteSyncError. + // Before the filter the stale task reported an error right after the fresh sync + // started, so the status UI showed a failure for the whole healthy run. + testWidgets('a cancelled sync does not report a false error to the status callbacks', (tester) async { + final errors = []; + final manager = BackgroundSyncManager(onRemoteSyncError: errors.add); + + // Hold the stream open so the task is genuinely in-flight when it is cancelled. + unawaited(manager.syncRemote()); + await server + .streamOpenedNth(1) + .timeout(const Duration(seconds: 30), onTimeout: () => fail('sync isolate never opened /sync/stream')); + + await manager.cancelResumeSyncs(); + // Let the cancelled task's completion chain run before checking the callbacks. + await Future.delayed(const Duration(milliseconds: 100)); + + expect( + errors, + isEmpty, + reason: 'a cancelled task is not a real error; its CanceledError must not fire onRemoteSyncError', + ); + }); +} diff --git a/mobile/integration_test/module_login/login_input_validation_test.dart b/mobile/integration_test/module_login/login_input_validation_test.dart index 1b47b6d8cc..504940f643 100644 --- a/mobile/integration_test/module_login/login_input_validation_test.dart +++ b/mobile/integration_test/module_login/login_input_validation_test.dart @@ -1,5 +1,5 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import '../test_utils/general_helper.dart'; @@ -7,45 +7,32 @@ void main() async { await ImmichTestHelper.initialize(); group("Login input validation test", () { - immichWidgetTest("Test leading/trailing whitespace", - (tester, helper) async { + immichWidgetTest("Test leading/trailing whitespace", (tester, helper) async { await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.acknowledgeNewServerVersion(); - await helper.loginHelper.enterCredentials( - email: " demo@immich.app", - ); + await helper.loginHelper.enterCredentials(email: " demo@immich.app"); await tester.pump(const Duration(milliseconds: 300)); - expect( - find.text("login_form_err_leading_whitespace".tr()), - findsOneWidget, - ); + expect(find.text(StaticTranslations.instance.login_form_err_leading_whitespace), findsOneWidget); - await helper.loginHelper.enterCredentials( - email: "demo@immich.app ", - ); + await helper.loginHelper.enterCredentials(email: "demo@immich.app "); await tester.pump(const Duration(milliseconds: 300)); - expect( - find.text("login_form_err_trailing_whitespace".tr()), - findsOneWidget, - ); + expect(find.text(StaticTranslations.instance.login_form_err_trailing_whitespace), findsOneWidget); }); immichWidgetTest("Test invalid email", (tester, helper) async { await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.acknowledgeNewServerVersion(); - await helper.loginHelper.enterCredentials( - email: "demo.immich.app", - ); + await helper.loginHelper.enterCredentials(email: "demo.immich.app"); await tester.pump(const Duration(milliseconds: 300)); - expect(find.text("login_form_err_invalid_email".tr()), findsOneWidget); + expect(find.text(StaticTranslations.instance.login_form_err_invalid_email), findsOneWidget); }); }); } diff --git a/mobile/integration_test/test_utils/fake_immich_server.dart b/mobile/integration_test/test_utils/fake_immich_server.dart index c434f83bc5..7c63098c52 100644 --- a/mobile/integration_test/test_utils/fake_immich_server.dart +++ b/mobile/integration_test/test_utils/fake_immich_server.dart @@ -10,6 +10,8 @@ class FakeImmichServer { final (int, int, int) version; final Completer _streamOpened = Completer(); + final List _streamOpens = []; + final Map> _openWaiters = {}; int ackRequests = 0; @@ -18,6 +20,17 @@ class FakeImmichServer { /// Resolves when the sync isolate opens `POST /sync/stream`. Future get streamOpened => _streamOpened.future; + /// How many `/sync/stream` requests have opened so far. + int get streamOpenCount => _streamOpens.length; + + /// Resolves when the [n]-th (1-indexed) `/sync/stream` opens. + Future streamOpenedNth(int n) { + if (_streamOpens.length >= n) { + return Future.value(_streamOpens[n - 1]); + } + return (_openWaiters[n] ??= Completer()).future; + } + static Future start({(int, int, int) version = (3, 0, 0)}) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final fake = FakeImmichServer._(server, version); @@ -58,13 +71,17 @@ class FakeImmichServer { request.response ..statusCode = HttpStatus.ok ..headers.contentType = ContentType('application', 'jsonlines+json') - ..contentLength = -1 // chunked: stays open to stream incrementally + ..contentLength = + -1 // chunked: stays open to stream incrementally ..bufferOutput = false; // Flush headers so the client's send() resolves and enters its read loop. await request.response.flush(); + final stream = SyncStream._(request.response); + _streamOpens.add(stream); if (!_streamOpened.isCompleted) { - _streamOpened.complete(SyncStream._(request.response)); + _streamOpened.complete(stream); } + _openWaiters.remove(_streamOpens.length)?.complete(stream); } Future _respondJson(HttpRequest request, Object body) async { @@ -83,8 +100,8 @@ class FakeImmichServer { } Future close() async { - if (_streamOpened.isCompleted) { - await (await _streamOpened.future).close(); + for (final stream in _streamOpens) { + await stream.close(); } await _server.close(force: true); } diff --git a/mobile/integration_test/test_utils/login_helper.dart b/mobile/integration_test/test_utils/login_helper.dart index cfbc5a9214..ad61b77a53 100644 --- a/mobile/integration_test/test_utils/login_helper.dart +++ b/mobile/integration_test/test_utils/login_helper.dart @@ -1,6 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'general_helper.dart'; @@ -23,11 +23,7 @@ class ImmichTestLoginHelper { return true; } - Future enterCredentials({ - String server = "", - String email = "", - String password = "", - }) async { + Future enterCredentials({String server = "", String email = "", String password = ""}) async { final loginForms = find.byType(TextFormField); await tester.enterText(loginForms.at(0), email); @@ -44,49 +40,30 @@ class ImmichTestLoginHelper { } Future enterCredentialsOf(LoginCredentials credentials) async { - await enterCredentials( - server: credentials.server, - email: credentials.email, - password: credentials.password, - ); + await enterCredentials(server: credentials.server, email: credentials.email, password: credentials.password); } Future pressLoginButton() async { - await pumpUntilFound( - tester, - find.textContaining("login_form_button_text".tr()), - ); - final button = find.textContaining("login_form_button_text".tr()); + await pumpUntilFound(tester, find.textContaining(StaticTranslations.instance.login)); + final button = find.textContaining(StaticTranslations.instance.login); await tester.tap(button); } Future assertLoginSuccess() async { - await pumpUntilFound(tester, find.text("home_page_building_timeline".tr())); + await pumpUntilFound(tester, find.text(StaticTranslations.instance.home_page_building_timeline)); } Future assertLoginFailed() async { - await pumpUntilFound(tester, find.text("login_form_failed_login".tr())); + await pumpUntilFound(tester, find.text(StaticTranslations.instance.login_form_failed_login)); } } enum LoginCredentials { - testInstance( - "https://flutter-int-test.preview.immich.app", - "demo@immich.app", - "demo", - ), + testInstance("https://flutter-int-test.preview.immich.app", "demo@immich.app", "demo"), - testInstanceButWithWrongPassword( - "https://flutter-int-test.preview.immich.app", - "demo@immich.app", - "wrong", - ), + testInstanceButWithWrongPassword("https://flutter-int-test.preview.immich.app", "demo@immich.app", "wrong"), - wrongInstanceUrl( - "https://does-not-exist.preview.immich.app", - "demo@immich.app", - "demo", - ); + wrongInstanceUrl("https://does-not-exist.preview.immich.app", "demo@immich.app", "demo"); const LoginCredentials(this.server, this.email, this.password); diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 9c142da054..0d023ad466 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -20,6 +20,7 @@ class LocalImageApiImpl: LocalImageApi { requestOptions.version = .current return requestOptions }() + private static let maxPixelSize: CGFloat = 16384 private static let registry = RequestRegistry() @@ -108,11 +109,16 @@ class LocalImageApiImpl: LocalImageApi { ])) } + let isOriginal = !(width > 0 && height > 0) + let targetSize = isOriginal + ? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize) + : CGSize(width: Double(width), height: Double(height)) + let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill var image: UIImage? Self.imageManager.requestImage( for: asset, - targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize, - contentMode: .aspectFill, + targetSize: targetSize, + contentMode: contentMode, options: Self.requestOptions, resultHandler: { (_image, info) -> Void in image = _image @@ -123,12 +129,38 @@ class LocalImageApiImpl: LocalImageApi { return request.completion(ImageProcessing.cancelledResult) } - guard let image = image, - let cgImage = image.cgImage else { + guard let fastImage = image, + var cgImage = fastImage.cgImage else { Self.registry.remove(requestId: requestId) return request.completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } + // .fast can return larger than the target, so retry with .exact to guarantee the bound. + if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) { + let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions + exactOptions.resizeMode = .exact + image = nil + Self.imageManager.requestImage( + for: asset, + targetSize: targetSize, + contentMode: contentMode, + options: exactOptions, + resultHandler: { (_image, info) -> Void in + image = _image + } + ) + + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } + + guard let exactImage = image?.cgImage else { + Self.registry.remove(requestId: requestId) + return request.completion(.failure(PigeonError(code: "", message: "Could not resize image for \(assetId)", details: nil))) + } + cgImage = exactImage + } + if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index de1f6dec89..b4b70f0b30 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -1,5 +1,6 @@ import Accelerate import Flutter +import ImageIO import MobileCoreServices import Photos @@ -27,21 +28,21 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), renderingIntent: .perceptual )! - private static let decodeOptions = [ + private static let decodeOptions: [NSString: Bool] = [ kCGImageSourceShouldCache: false, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceCreateThumbnailFromImageAlways: true - ] as CFDictionary + ] - func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + func requestImage(url: String, requestId: Int64, preferEncoded: Bool, width: Int64?, height: Int64?, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { var urlRequest = URLRequest(url: URL(string: url)!) urlRequest.cachePolicy = .returnCacheDataElseLoad let request = RemoteImageRequest(id: requestId, completion: completion) let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in - Self.handleCompletion(request: request, encoded: preferEncoded, data: data, response: response, error: error) + Self.handleCompletion(request: request, encoded: preferEncoded, width: width, height: height, data: data, response: response, error: error) } request.task = task @@ -49,7 +50,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { task.resume() } - private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) { + private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, width: Int64?, height: Int64?, data: Data?, response: URLResponse?, error: Error?) { if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } @@ -87,8 +88,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return request.completion(ImageProcessing.cancelledResult) } - guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), - let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { + registry.remove(requestId: request.id) + return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) + } + + var options: [NSString: Any] = decodeOptions + if let maxPixelSize = targetThumbnailRenderSize(imageSource: imageSource, width: width, height: height) { + options[kCGImageSourceThumbnailMaxPixelSize] = maxPixelSize + } + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else { registry.remove(requestId: request.id) return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) } @@ -120,6 +130,34 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { } } + /// Returns the longest rendered edge needed to cover the requested size. + private static func targetThumbnailRenderSize(imageSource: CGImageSource, width: Int64?, height: Int64?) -> Int? { + guard let width, + let height, + width > 0, + height > 0, + let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], + let pixelWidth = properties[kCGImagePropertyPixelWidth] as? NSNumber, + let pixelHeight = properties[kCGImagePropertyPixelHeight] as? NSNumber else { + return nil + } + + let orientation = (properties[kCGImagePropertyOrientation] as? NSNumber) + .flatMap { CGImagePropertyOrientation(rawValue: $0.uint32Value) } ?? .up + let swapsDimensions: Bool + switch orientation { + case .leftMirrored, .right, .rightMirrored, .left: + swapsDimensions = true + default: + swapsDimensions = false + } + let sourceWidth = swapsDimensions ? pixelHeight.doubleValue : pixelWidth.doubleValue + let sourceHeight = swapsDimensions ? pixelWidth.doubleValue : pixelHeight.doubleValue + let fillScale = max(Double(width) / sourceWidth, Double(height) / sourceHeight) + let scale = min(1, fillScale) + return scale < 1 ? Int(ceil(max(sourceWidth * scale, sourceHeight * scale))) : nil + } + func cancelRequest(requestId: Int64) { Self.registry.remove(requestId: requestId)?.cancel() } diff --git a/mobile/ios/WidgetExtension/ImageEntry.swift b/mobile/ios/WidgetExtension/ImageEntry.swift index ee371703a8..58f89339ac 100644 --- a/mobile/ios/WidgetExtension/ImageEntry.swift +++ b/mobile/ios/WidgetExtension/ImageEntry.swift @@ -12,13 +12,15 @@ struct ImageEntry: TimelineEntry { var subtitle: String? = nil var error: WidgetError? = nil var deepLink: URL? = nil + var forceFullColor: Bool = true } static func build( api: ImmichAPI, asset: Asset, dateOffset: Int, - subtitle: String? = nil + subtitle: String? = nil, + forceFullColor: Bool = true ) async throws -> Self { @@ -34,7 +36,8 @@ struct ImageEntry: TimelineEntry { image: image, metadata: EntryMetadata( subtitle: subtitle, - deepLink: asset.deepLink + deepLink: asset.deepLink, + forceFullColor: forceFullColor ) ) } @@ -111,12 +114,26 @@ struct ImageEntry: TimelineEntry { } +// Required so that we can decode older versions of the metadata +extension ImageEntry.Metadata { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle) + error = try container.decodeIfPresent(WidgetError.self, forKey: .error) + deepLink = try container.decodeIfPresent(URL.self, forKey: .deepLink) + + forceFullColor = + try container.decodeIfPresent(Bool.self, forKey: .forceFullColor) ?? true + } +} + func generateRandomEntries( api: ImmichAPI, now: Date, count: Int, filter: SearchFilter = Album.NONE.filter, - subtitle: String? = nil + subtitle: String? = nil, + forceFullColor: Bool = true ) async throws -> [ImageEntry] { @@ -132,7 +149,8 @@ func generateRandomEntries( api: api, asset: asset, dateOffset: dateOffset, - subtitle: subtitle + subtitle: subtitle, + forceFullColor: forceFullColor ) } } diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift index 17088d43b8..2e3a816d92 100644 --- a/mobile/ios/WidgetExtension/ImageWidgetView.swift +++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift @@ -3,10 +3,10 @@ import WidgetKit extension Image { @ViewBuilder - func tintedWidgetImageModifier() -> some View { + func tintedWidgetImageModifier(forceFullColor: Bool) -> some View { if #available(iOS 18.0, *) { self - .widgetAccentedRenderingMode(.accentedDesaturated) + .widgetAccentedRenderingMode(forceFullColor ? .accentedDesaturated : .fullColor) } else { self } @@ -18,15 +18,24 @@ struct ImmichWidgetView: View { var body: some View { if let image = entry.image { - ImmichWidgetContentView(image: image, subtitle: entry.metadata.subtitle, deepLink: entry.metadata.deepLink) + ImmichWidgetContentView( + image: image, + subtitle: entry.metadata.subtitle, + deepLink: entry.metadata.deepLink, + forceFullColor: entry.metadata.forceFullColor + ) } else { - ImmichWidgetLoadingView(message: entry.metadata.error?.errorDescription) + ImmichWidgetLoadingView( + message: entry.metadata.error?.errorDescription, + forceFullColor: entry.metadata.forceFullColor + ) } } } private struct ImmichWidgetLoadingView: View { let message: String? + let forceFullColor: Bool var body: some View { let messageText = Text(message ?? "") @@ -39,7 +48,7 @@ private struct ImmichWidgetLoadingView: View { messageText.hidden() Image("LaunchImage") - .tintedWidgetImageModifier() + .tintedWidgetImageModifier(forceFullColor: forceFullColor) messageText } @@ -50,13 +59,14 @@ private struct ImmichWidgetContentView: View { let image: UIImage let subtitle: String? let deepLink: URL? + let forceFullColor: Bool var body: some View { ZStack(alignment: .leading) { Color.clear.overlay( Image(uiImage: image) .resizable() - .tintedWidgetImageModifier() + .tintedWidgetImageModifier(forceFullColor: forceFullColor) .scaledToFill() ) diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift index 15fa3d722e..dee5a32159 100644 --- a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -2,7 +2,21 @@ import AppIntents import SwiftUI import WidgetKit -struct ImmichMemoryProvider: TimelineProvider { +// MARK: Widget Configuration + +struct MemoryConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Memories" } + static var description: IntentDescription { + "See memories from Immich." + } + + @Parameter(title: "Always Display in Full Color", default: false) + var forceFullColor: Bool +} + +// MARK: Provider + +struct ImmichMemoryProvider: AppIntentTimelineProvider { func getYearDifferenceSubtitle(assetYear: Int) -> String { let currentYear = Calendar.current.component(.year, from: Date.now) // construct a "X years ago" subtitle @@ -15,144 +29,132 @@ struct ImmichMemoryProvider: TimelineProvider { ImageEntry(date: Date(), image: nil) } - func getSnapshot( - in context: Context, - completion: @escaping @Sendable (ImageEntry) -> Void - ) { + func snapshot( + for configuration: MemoryConfigurationAppIntent, + in context: Context + ) async -> ImageEntry { let cacheKey = "memory_\(context.family.rawValue)" - Task { - guard let api = try? await ImmichAPI() else { - completion( - ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first! - ) - return - } - - guard let memories = try? await api.fetchMemory(for: Date.now) - else { - completion(ImageEntry.handleError(for: cacheKey).entries.first!) - return - } - - for memory in memories { - if let asset = memory.assets.first(where: { $0.type == .image }), - let entry = try? await ImageEntry.build( - api: api, - asset: asset, - dateOffset: 0, - subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year) - ) - { - completion(entry) - return - } - } - - // fallback to random image - guard - let randomImage = try? await api.fetchSearchResults().first, - let imageEntry = try? await ImageEntry.build( - api: api, - asset: randomImage, - dateOffset: 0 - ) - else { - completion(ImageEntry.handleError(for: cacheKey).entries.first!) - return - } - - completion(imageEntry) + guard let api = try? await ImmichAPI() else { + return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries + .first! } + + guard let memories = try? await api.fetchMemory(for: Date.now) else { + return ImageEntry.handleError(for: cacheKey).entries.first! + } + + for memory in memories { + if let asset = memory.assets.first(where: { $0.type == .image }), + let entry = try? await ImageEntry.build( + api: api, + asset: asset, + dateOffset: 0, + subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year), + forceFullColor: configuration.forceFullColor + ) + { + return entry + } + } + + // fallback to random image + guard + let randomImage = try? await api.fetchSearchResults().first, + let imageEntry = try? await ImageEntry.build( + api: api, + asset: randomImage, + dateOffset: 0, + forceFullColor: configuration.forceFullColor + ) + else { + return ImageEntry.handleError(for: cacheKey).entries.first! + } + + return imageEntry } - func getTimeline( - in context: Context, - completion: @escaping @Sendable (Timeline) -> Void - ) { - Task { - var entries: [ImageEntry] = [] - let now = Date() + func timeline( + for configuration: MemoryConfigurationAppIntent, + in context: Context + ) async + -> Timeline + { + var entries: [ImageEntry] = [] + let now = Date() - let cacheKey = "memory_\(context.family.rawValue)" + let cacheKey = "memory_\(context.family.rawValue)" - guard let api = try? await ImmichAPI() else { - completion( - ImageEntry.handleError(for: cacheKey, error: .noLogin) - ) - return - } - - let memories: [MemoryResult] - do { - memories = try await api.fetchMemory(for: Date.now) - - await withTaskGroup(of: ImageEntry?.self) { group in - var totalAssets = 0 - - for memory in memories { - for asset in memory.assets { - if asset.type == .image && totalAssets < 12 { - group.addTask { - try? await ImageEntry.build( - api: api, - asset: asset, - dateOffset: totalAssets, - subtitle: getYearDifferenceSubtitle( - assetYear: memory.data.year - ) - ) - } - - totalAssets += 1 - } - } - } - - for await result in group { - if let entry = result { - entries.append(entry) - } - } - } - - } catch { - completion(ImageEntry.handleError(for: cacheKey)) - return - } - - // If we didn't add any memory images (some failure occurred or no images in memory), - // default to 12 hours of random photos - if entries.count == 0 { - // this must be a do/catch since we need to - // distinguish between a network fail and an empty search - do { - let search = try await generateRandomEntries( - api: api, - now: now, - count: 12 - ) - - // Load or save a cached asset for when network conditions are bad - if search.count == 0 { - completion( - ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) - ) - return - } - - entries.append(contentsOf: search) - } catch { - completion(ImageEntry.handleError(for: cacheKey)) - return - } - } - - // cache the last image - try? entries.last!.cache(for: cacheKey) - - completion(Timeline(entries: entries, policy: .atEnd)) + guard let api = try? await ImmichAPI() else { + return ImageEntry.handleError(for: cacheKey, error: .noLogin) } + + let memories: [MemoryResult] + do { + memories = try await api.fetchMemory(for: Date.now) + + await withTaskGroup(of: ImageEntry?.self) { group in + var totalAssets = 0 + + for memory in memories { + for asset in memory.assets { + if asset.type == .image && totalAssets < 12 { + group.addTask { + try? await ImageEntry.build( + api: api, + asset: asset, + dateOffset: totalAssets, + subtitle: getYearDifferenceSubtitle( + assetYear: memory.data.year + ), + forceFullColor: configuration.forceFullColor + ) + } + + totalAssets += 1 + } + } + } + + for await result in group { + if let entry = result { + entries.append(entry) + } + } + } + + } catch { + return ImageEntry.handleError(for: cacheKey) + } + + // If we didn't add any memory images (some failure occurred or no images in memory), + // default to 12 hours of random photos + if entries.count == 0 { + // this must be a do/catch since we need to + // distinguish between a network fail and an empty search + do { + let search = try await generateRandomEntries( + api: api, + now: now, + count: 12, + forceFullColor: configuration.forceFullColor + ) + + // Load or save a cached asset for when network conditions are bad + if search.count == 0 { + return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) + } + + entries.append(contentsOf: search) + } catch { + return ImageEntry.handleError(for: cacheKey) + } + } + + // cache the last image + try? entries.last!.cache(for: cacheKey) + + return Timeline(entries: entries, policy: .atEnd) } } @@ -160,8 +162,9 @@ struct ImmichMemoryWidget: Widget { let kind: String = "com.immich.widget.memory" var body: some WidgetConfiguration { - StaticConfiguration( + AppIntentConfiguration( kind: kind, + intent: MemoryConfigurationAppIntent.self, provider: ImmichMemoryProvider() ) { entry in ImmichWidgetView(entry: entry) diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift index 37f3c5e596..d987464152 100644 --- a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -47,6 +47,9 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent { @Parameter(title: "Show Album Name", default: false) var showAlbumName: Bool + + @Parameter(title: "Always Display in Full Color", default: false) + var forceFullColor: Bool } // MARK: Provider @@ -76,7 +79,8 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { let entry = try? await ImageEntry.build( api: api, asset: randomImage, - dateOffset: 0 + dateOffset: 0, + forceFullColor: configuration.forceFullColor ) else { return ImageEntry.handleError(for: cacheKey).entries.first! @@ -114,7 +118,8 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { now: now, count: 12, filter: album.filter, - subtitle: configuration.showAlbumName ? albumName : nil + subtitle: configuration.showAlbumName ? albumName : nil, + forceFullColor: configuration.forceFullColor ) // Load or save a cached asset for when network conditions are bad diff --git a/mobile/lib/constants/aspect_ratios.dart b/mobile/lib/constants/aspect_ratios.dart index 9ad7b8c739..49165e0913 100644 --- a/mobile/lib/constants/aspect_ratios.dart +++ b/mobile/lib/constants/aspect_ratios.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; -class CropAspectRatio { - final int? numerator; - final int? denominator; +part 'aspect_ratios.freezed.dart'; - final String? customLabel; - final IconData? icon; +@freezed +abstract class CropAspectRatio with _$CropAspectRatio { + const CropAspectRatio._(); - const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon}); + const factory CropAspectRatio({int? numerator, int? denominator, String? customLabel, IconData? icon}) = + _CropAspectRatio; static const free = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free); static const original = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original); @@ -22,24 +23,6 @@ class CropAspectRatio { CropAspectRatio get flipped { return CropAspectRatio(numerator: denominator, denominator: numerator, customLabel: customLabel, icon: icon); } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is CropAspectRatio && - other.numerator == numerator && - other.denominator == denominator && - other.customLabel == customLabel && - other.icon == icon; - } - - @override - int get hashCode { - return numerator.hashCode ^ denominator.hashCode ^ customLabel.hashCode ^ icon.hashCode; - } } const aspectRatioFree = CropAspectRatio.free; diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index d59c48c045..d3f85ceb2e 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -24,3 +24,14 @@ enum SlideshowLook { contain, cover, blurredBackground } enum SlideshowDirection { forward, backward, shuffle } enum PartnerDirection { sharedBy, sharedWith } + +enum DevicePermission { photos, videos, storage, mediaLocation } + +enum DevicePermissionStatus { + denied, + granted, + limited, + permanentlyDenied; + + bool get hasAccess => this == granted || this == limited; +} diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart index 63f4ed6be3..e63a3774e1 100644 --- a/mobile/lib/domain/models/album/album.model.dart +++ b/mobile/lib/domain/models/album/album.model.dart @@ -1,3 +1,8 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'album.model.freezed.dart'; + enum AlbumAssetOrder { // do not change this order! asc, @@ -12,118 +17,20 @@ enum AlbumUserRole { } // Model for an album stored in the server -class RemoteAlbum { - final String id; - final String name; - final String ownerId; - final String description; - final DateTime createdAt; - final DateTime updatedAt; - final String? thumbnailAssetId; - final bool isActivityEnabled; - final AlbumAssetOrder order; - final int assetCount; - final String ownerName; - final bool isShared; - - const RemoteAlbum({ - required this.id, - required this.name, - required this.ownerId, - required this.description, - required this.createdAt, - required this.updatedAt, - this.thumbnailAssetId, - required this.isActivityEnabled, - required this.order, - required this.assetCount, - required this.ownerName, - required this.isShared, - }); - - @override - String toString() { - return '''Album { - id: $id, - name: $name, - ownerId: $ownerId, - description: $description, - createdAt: $createdAt, - updatedAt: $updatedAt, - isActivityEnabled: $isActivityEnabled, - order: $order, - thumbnailAssetId: ${thumbnailAssetId ?? ""} - assetCount: $assetCount - ownerName: $ownerName - isShared: $isShared - }'''; - } - - @override - bool operator ==(Object other) { - if (other is! RemoteAlbum) { - return false; - } - if (identical(this, other)) { - return true; - } - return id == other.id && - name == other.name && - ownerId == other.ownerId && - description == other.description && - createdAt == other.createdAt && - updatedAt == other.updatedAt && - thumbnailAssetId == other.thumbnailAssetId && - isActivityEnabled == other.isActivityEnabled && - order == other.order && - assetCount == other.assetCount && - ownerName == other.ownerName && - isShared == other.isShared; - } - - @override - int get hashCode { - return id.hashCode ^ - name.hashCode ^ - ownerId.hashCode ^ - description.hashCode ^ - createdAt.hashCode ^ - updatedAt.hashCode ^ - thumbnailAssetId.hashCode ^ - isActivityEnabled.hashCode ^ - order.hashCode ^ - assetCount.hashCode ^ - ownerName.hashCode ^ - isShared.hashCode; - } - - RemoteAlbum copyWith({ - String? id, - String? name, - String? ownerId, - String? description, - DateTime? createdAt, - DateTime? updatedAt, +@freezed +abstract class RemoteAlbum with _$RemoteAlbum { + const factory RemoteAlbum({ + required String id, + required String name, + required String ownerId, + required String description, + required DateTime createdAt, + required DateTime updatedAt, String? thumbnailAssetId, - bool? isActivityEnabled, - AlbumAssetOrder? order, - int? assetCount, - String? ownerName, - bool? isShared, - }) { - return RemoteAlbum( - id: id ?? this.id, - name: name ?? this.name, - ownerId: ownerId ?? this.ownerId, - description: description ?? this.description, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, - isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, - order: order ?? this.order, - assetCount: assetCount ?? this.assetCount, - ownerName: ownerName ?? this.ownerName, - isShared: isShared ?? this.isShared, - ); - } + required bool isActivityEnabled, + required AlbumAssetOrder order, + required int assetCount, + required String ownerName, + required bool isShared, + }) = _RemoteAlbum; } diff --git a/mobile/lib/domain/models/album/local_album.model.dart b/mobile/lib/domain/models/album/local_album.model.dart index 9e8521fa02..cddc1d1953 100644 --- a/mobile/lib/domain/models/album/local_album.model.dart +++ b/mobile/lib/domain/models/album/local_album.model.dart @@ -1,3 +1,8 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'local_album.model.freezed.dart'; + enum BackupSelection { // Used to sort albums based on the backupSelection // selected -> none -> excluded @@ -7,85 +12,15 @@ enum BackupSelection { excluded, } -class LocalAlbum { - final String id; - final String name; - final DateTime updatedAt; - final bool isIosSharedAlbum; - - final int assetCount; - final BackupSelection backupSelection; - final String? linkedRemoteAlbumId; - - const LocalAlbum({ - required this.id, - required this.name, - required this.updatedAt, - this.assetCount = 0, - this.backupSelection = BackupSelection.none, - this.isIosSharedAlbum = false, - this.linkedRemoteAlbumId, - }); - - LocalAlbum copyWith({ - String? id, - String? name, - DateTime? updatedAt, - int? assetCount, - BackupSelection? backupSelection, - bool? isIosSharedAlbum, +@freezed +abstract class LocalAlbum with _$LocalAlbum { + const factory LocalAlbum({ + required String id, + required String name, + required DateTime updatedAt, + @Default(0) int assetCount, + @Default(BackupSelection.none) BackupSelection backupSelection, + @Default(false) bool isIosSharedAlbum, String? linkedRemoteAlbumId, - }) { - return LocalAlbum( - id: id ?? this.id, - name: name ?? this.name, - updatedAt: updatedAt ?? this.updatedAt, - assetCount: assetCount ?? this.assetCount, - backupSelection: backupSelection ?? this.backupSelection, - isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, - linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId, - ); - } - - @override - bool operator ==(Object other) { - if (other is! LocalAlbum) { - return false; - } - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.name == name && - other.updatedAt == updatedAt && - other.assetCount == assetCount && - other.backupSelection == backupSelection && - other.isIosSharedAlbum == isIosSharedAlbum && - other.linkedRemoteAlbumId == linkedRemoteAlbumId; - } - - @override - int get hashCode { - return id.hashCode ^ - name.hashCode ^ - updatedAt.hashCode ^ - assetCount.hashCode ^ - backupSelection.hashCode ^ - isIosSharedAlbum.hashCode ^ - linkedRemoteAlbumId.hashCode; - } - - @override - String toString() { - return '''LocalAlbum: { -id: $id, -name: $name, -updatedAt: $updatedAt, -assetCount: $assetCount, -backupSelection: $backupSelection, -isIosSharedAlbum: $isIosSharedAlbum -linkedRemoteAlbumId: $linkedRemoteAlbumId, -}'''; - } + }) = _LocalAlbum; } diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index d7d74daa25..ac89fe9a0a 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -77,6 +77,7 @@ sealed class BaseAsset { // Overridden in subclasses AssetState get storage; + String get id; String? get localId; String? get remoteId; String get heroTag; diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 0ad4c8f39b..15039761a8 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -1,6 +1,7 @@ part of 'base_asset.model.dart'; class LocalAsset extends BaseAsset { + @override final String id; final String? remoteAssetId; final String? cloudId; diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 387a817eab..5707329ba3 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -4,6 +4,7 @@ enum AssetVisibility { timeline, hidden, archive, locked } // Model for an asset stored in the server class RemoteAsset extends BaseAsset { + @override final String id; final String? localAssetId; final String? thumbHash; diff --git a/mobile/lib/domain/models/asset_face.model.dart b/mobile/lib/domain/models/asset_face.model.dart index 1388836946..c65f159fe7 100644 --- a/mobile/lib/domain/models/asset_face.model.dart +++ b/mobile/lib/domain/models/asset_face.model.dart @@ -1,100 +1,21 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'asset_face.model.freezed.dart'; + // Model for an asset face stored in the server -class AssetFace { - final String id; - final String assetId; - final String? personId; - final int imageWidth; - final int imageHeight; - final int boundingBoxX1; - final int boundingBoxY1; - final int boundingBoxX2; - final int boundingBoxY2; - final String sourceType; - - const AssetFace({ - required this.id, - required this.assetId, - this.personId, - required this.imageWidth, - required this.imageHeight, - required this.boundingBoxX1, - required this.boundingBoxY1, - required this.boundingBoxX2, - required this.boundingBoxY2, - required this.sourceType, - }); - - AssetFace copyWith({ - String? id, - String? assetId, +@freezed +abstract class AssetFace with _$AssetFace { + const factory AssetFace({ + required String id, + required String assetId, String? personId, - int? imageWidth, - int? imageHeight, - int? boundingBoxX1, - int? boundingBoxY1, - int? boundingBoxX2, - int? boundingBoxY2, - String? sourceType, - }) { - return AssetFace( - id: id ?? this.id, - assetId: assetId ?? this.assetId, - personId: personId ?? this.personId, - imageWidth: imageWidth ?? this.imageWidth, - imageHeight: imageHeight ?? this.imageHeight, - boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, - boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, - boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, - boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, - sourceType: sourceType ?? this.sourceType, - ); - } - - @override - String toString() { - return '''AssetFace { - id: $id, - assetId: $assetId, - personId: ${personId ?? ""}, - imageWidth: $imageWidth, - imageHeight: $imageHeight, - boundingBoxX1: $boundingBoxX1, - boundingBoxY1: $boundingBoxY1, - boundingBoxX2: $boundingBoxX2, - boundingBoxY2: $boundingBoxY2, - sourceType: $sourceType, -}'''; - } - - @override - bool operator ==(covariant AssetFace other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.assetId == assetId && - other.personId == personId && - other.imageWidth == imageWidth && - other.imageHeight == imageHeight && - other.boundingBoxX1 == boundingBoxX1 && - other.boundingBoxY1 == boundingBoxY1 && - other.boundingBoxX2 == boundingBoxX2 && - other.boundingBoxY2 == boundingBoxY2 && - other.sourceType == sourceType; - } - - @override - int get hashCode { - return id.hashCode ^ - assetId.hashCode ^ - personId.hashCode ^ - imageWidth.hashCode ^ - imageHeight.hashCode ^ - boundingBoxX1.hashCode ^ - boundingBoxY1.hashCode ^ - boundingBoxX2.hashCode ^ - boundingBoxY2.hashCode ^ - sourceType.hashCode; - } + required int imageWidth, + required int imageHeight, + required int boundingBoxX1, + required int boundingBoxY1, + required int boundingBoxX2, + required int boundingBoxY2, + required String sourceType, + }) = _AssetFace; } diff --git a/mobile/lib/domain/models/config/album_config.dart b/mobile/lib/domain/models/config/album_config.dart index a83fc32fbf..16deca1b8f 100644 --- a/mobile/lib/domain/models/config/album_config.dart +++ b/mobile/lib/domain/models/config/album_config.dart @@ -1,26 +1,14 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -class AlbumConfig { - final AlbumSortMode sortMode; - final bool isReverse; - final bool isGrid; +part 'album_config.freezed.dart'; - const AlbumConfig({this.sortMode = AlbumSortMode.mostRecent, this.isReverse = true, this.isGrid = false}); - - AlbumConfig copyWith({AlbumSortMode? sortMode, bool? isReverse, bool? isGrid}) => AlbumConfig( - sortMode: sortMode ?? this.sortMode, - isReverse: isReverse ?? this.isReverse, - isGrid: isGrid ?? this.isGrid, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AlbumConfig && other.sortMode == sortMode && other.isReverse == isReverse && other.isGrid == isGrid); - - @override - int get hashCode => Object.hash(sortMode, isReverse, isGrid); - - @override - String toString() => 'AlbumConfig(sortMode: $sortMode, isReverse: $isReverse, isGrid: $isGrid)'; +@freezed +abstract class AlbumConfig with _$AlbumConfig { + const factory AlbumConfig({ + @Default(AlbumSortMode.mostRecent) AlbumSortMode sortMode, + @Default(true) bool isReverse, + @Default(false) bool isGrid, + }) = _AlbumConfig; } diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index df147f3e3a..bc089e621e 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/config/album_config.dart'; @@ -19,107 +21,29 @@ import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/utils/semver.dart'; +part 'app_config.freezed.dart'; + const defaultConfig = AppConfig(); -class AppConfig { - final LogLevel logLevel; - final ThemeConfig theme; - final CleanupConfig cleanup; - final MapConfig map; - final TimelineConfig timeline; - final ImageConfig image; - final ViewerConfig viewer; - final SlideshowConfig slideshow; - final AlbumConfig album; - final BackupConfig backup; - final NetworkConfig network; - final ShareConfig share; - final FeatureMessageConfig featureMessage; +@freezed +abstract class AppConfig with _$AppConfig { + const AppConfig._(); - const AppConfig({ - this.logLevel = .info, - this.theme = const .new(), - this.cleanup = const .new(), - this.map = const .new(), - this.timeline = const .new(), - this.image = const .new(), - this.viewer = const .new(), - this.slideshow = const .new(), - this.album = const .new(), - this.backup = const .new(), - this.network = const .new(), - this.share = const .new(), - this.featureMessage = const .new(), - }); - - AppConfig copyWith({ - LogLevel? logLevel, - ThemeConfig? theme, - CleanupConfig? cleanup, - MapConfig? map, - TimelineConfig? timeline, - ImageConfig? image, - ViewerConfig? viewer, - SlideshowConfig? slideshow, - AlbumConfig? album, - BackupConfig? backup, - NetworkConfig? network, - ShareConfig? share, - FeatureMessageConfig? featureMessage, - }) => .new( - logLevel: logLevel ?? this.logLevel, - theme: theme ?? this.theme, - cleanup: cleanup ?? this.cleanup, - map: map ?? this.map, - timeline: timeline ?? this.timeline, - image: image ?? this.image, - viewer: viewer ?? this.viewer, - slideshow: slideshow ?? this.slideshow, - album: album ?? this.album, - backup: backup ?? this.backup, - network: network ?? this.network, - share: share ?? this.share, - featureMessage: featureMessage ?? this.featureMessage, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AppConfig && - other.logLevel == logLevel && - other.theme == theme && - other.cleanup == cleanup && - other.map == map && - other.timeline == timeline && - other.image == image && - other.viewer == viewer && - other.slideshow == slideshow && - other.album == album && - other.backup == backup && - other.network == network && - other.share == share && - other.featureMessage == featureMessage); - - @override - int get hashCode => Object.hash( - logLevel, - theme, - cleanup, - map, - timeline, - image, - viewer, - slideshow, - album, - backup, - network, - share, - featureMessage, - ); - - @override - String toString() => - 'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)'; + const factory AppConfig({ + @Default(LogLevel.info) LogLevel logLevel, + @Default(ThemeConfig()) ThemeConfig theme, + @Default(CleanupConfig()) CleanupConfig cleanup, + @Default(MapConfig()) MapConfig map, + @Default(TimelineConfig()) TimelineConfig timeline, + @Default(ImageConfig()) ImageConfig image, + @Default(ViewerConfig()) ViewerConfig viewer, + @Default(SlideshowConfig()) SlideshowConfig slideshow, + @Default(AlbumConfig()) AlbumConfig album, + @Default(BackupConfig()) BackupConfig backup, + @Default(NetworkConfig()) NetworkConfig network, + @Default(ShareConfig()) ShareConfig share, + @Default(FeatureMessageConfig()) FeatureMessageConfig featureMessage, + }) = _AppConfig; T read(SettingsKey key) => (switch (key) { diff --git a/mobile/lib/domain/models/config/backup_config.dart b/mobile/lib/domain/models/config/backup_config.dart index 19f91a4ed7..4177408fcc 100644 --- a/mobile/lib/domain/models/config/backup_config.dart +++ b/mobile/lib/domain/models/config/backup_config.dart @@ -1,52 +1,16 @@ -class BackupConfig { - final bool enabled; - final bool useCellularForVideos; - final bool useCellularForPhotos; - final bool requireCharging; - final int triggerDelay; - final bool syncAlbums; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const BackupConfig({ - this.enabled = false, - this.useCellularForVideos = false, - this.useCellularForPhotos = false, - this.requireCharging = false, - this.triggerDelay = 30, - this.syncAlbums = false, - }); +part 'backup_config.freezed.dart'; - BackupConfig copyWith({ - bool? enabled, - bool? useCellularForVideos, - bool? useCellularForPhotos, - bool? requireCharging, - int? triggerDelay, - bool? syncAlbums, - }) => BackupConfig( - enabled: enabled ?? this.enabled, - useCellularForVideos: useCellularForVideos ?? this.useCellularForVideos, - useCellularForPhotos: useCellularForPhotos ?? this.useCellularForPhotos, - requireCharging: requireCharging ?? this.requireCharging, - triggerDelay: triggerDelay ?? this.triggerDelay, - syncAlbums: syncAlbums ?? this.syncAlbums, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is BackupConfig && - other.enabled == enabled && - other.useCellularForVideos == useCellularForVideos && - other.useCellularForPhotos == useCellularForPhotos && - other.requireCharging == requireCharging && - other.triggerDelay == triggerDelay && - other.syncAlbums == syncAlbums); - - @override - int get hashCode => - Object.hash(enabled, useCellularForVideos, useCellularForPhotos, requireCharging, triggerDelay, syncAlbums); - - @override - String toString() => - 'BackupConfig(enabled: $enabled, useCellularForVideos: $useCellularForVideos, useCellularForPhotos: $useCellularForPhotos, requireCharging: $requireCharging, triggerDelay: $triggerDelay, syncAlbums: $syncAlbums)'; +@freezed +abstract class BackupConfig with _$BackupConfig { + const factory BackupConfig({ + @Default(false) bool enabled, + @Default(false) bool useCellularForVideos, + @Default(false) bool useCellularForPhotos, + @Default(false) bool requireCharging, + @Default(30) int triggerDelay, + @Default(false) bool syncAlbums, + }) = _BackupConfig; } diff --git a/mobile/lib/domain/models/config/feature_message_config.dart b/mobile/lib/domain/models/config/feature_message_config.dart index d5c7c69550..0fc13fd51f 100644 --- a/mobile/lib/domain/models/config/feature_message_config.dart +++ b/mobile/lib/domain/models/config/feature_message_config.dart @@ -1,20 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/utils/semver.dart'; -class FeatureMessageConfig { - final SemVer seenRelease; +part 'feature_message_config.freezed.dart'; - const FeatureMessageConfig({this.seenRelease = const SemVer(major: 0, minor: 0, patch: 0)}); - - FeatureMessageConfig copyWith({SemVer? seenRelease}) => - FeatureMessageConfig(seenRelease: seenRelease ?? this.seenRelease); - - @override - bool operator ==(Object other) => - identical(this, other) || (other is FeatureMessageConfig && other.seenRelease == seenRelease); - - @override - int get hashCode => seenRelease.hashCode; - - @override - String toString() => 'FeatureMessageConfig(seenRelease: $seenRelease)'; +@freezed +abstract class FeatureMessageConfig with _$FeatureMessageConfig { + const factory FeatureMessageConfig({@Default(SemVer(major: 0, minor: 0, patch: 0)) SemVer seenRelease}) = + _FeatureMessageConfig; } diff --git a/mobile/lib/domain/models/config/image_config.dart b/mobile/lib/domain/models/config/image_config.dart index 8410a9010b..a51193d1d3 100644 --- a/mobile/lib/domain/models/config/image_config.dart +++ b/mobile/lib/domain/models/config/image_config.dart @@ -1,20 +1,9 @@ -class ImageConfig { - final bool preferRemote; - final bool loadOriginal; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const ImageConfig({this.preferRemote = false, this.loadOriginal = false}); +part 'image_config.freezed.dart'; - ImageConfig copyWith({bool? preferRemote, bool? loadOriginal}) => - ImageConfig(preferRemote: preferRemote ?? this.preferRemote, loadOriginal: loadOriginal ?? this.loadOriginal); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ImageConfig && other.preferRemote == preferRemote && other.loadOriginal == loadOriginal); - - @override - int get hashCode => Object.hash(preferRemote, loadOriginal); - - @override - String toString() => 'ImageConfig(preferRemoteImage: $preferRemote, loadOriginal: $loadOriginal)'; +@freezed +abstract class ImageConfig with _$ImageConfig { + const factory ImageConfig({@Default(false) bool preferRemote, @Default(false) bool loadOriginal}) = _ImageConfig; } diff --git a/mobile/lib/domain/models/config/share_config.dart b/mobile/lib/domain/models/config/share_config.dart index 898867ff78..49530aca30 100644 --- a/mobile/lib/domain/models/config/share_config.dart +++ b/mobile/lib/domain/models/config/share_config.dart @@ -1,18 +1,10 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/constants/enums.dart'; -class ShareConfig { - final ShareAssetType fileType; +part 'share_config.freezed.dart'; - const ShareConfig({this.fileType = ShareAssetType.original}); - - ShareConfig copyWith({ShareAssetType? fileType}) => ShareConfig(fileType: fileType ?? this.fileType); - - @override - bool operator ==(Object other) => identical(this, other) || (other is ShareConfig && other.fileType == fileType); - - @override - int get hashCode => fileType.hashCode; - - @override - String toString() => 'ShareConfig(fileType: $fileType)'; +@freezed +abstract class ShareConfig with _$ShareConfig { + const factory ShareConfig({@Default(ShareAssetType.original) ShareAssetType fileType}) = _ShareConfig; } diff --git a/mobile/lib/domain/models/config/slideshow_config.dart b/mobile/lib/domain/models/config/slideshow_config.dart index 6bcdaadc77..cacba1a89e 100644 --- a/mobile/lib/domain/models/config/slideshow_config.dart +++ b/mobile/lib/domain/models/config/slideshow_config.dart @@ -1,38 +1,15 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/constants/enums.dart'; -class SlideshowConfig { - final bool repeat; - final int duration; - final SlideshowLook look; - final SlideshowDirection direction; +part 'slideshow_config.freezed.dart'; - const SlideshowConfig({ - this.repeat = true, - this.duration = 5, - this.look = SlideshowLook.blurredBackground, - this.direction = SlideshowDirection.forward, - }); - - SlideshowConfig copyWith({bool? repeat, int? duration, SlideshowLook? look, SlideshowDirection? direction}) => - SlideshowConfig( - repeat: repeat ?? this.repeat, - duration: duration ?? this.duration, - look: look ?? this.look, - direction: direction ?? this.direction, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is SlideshowConfig && - other.repeat == repeat && - other.duration == duration && - other.look == look && - other.direction == direction); - - @override - int get hashCode => Object.hash(repeat, duration, look, direction); - - @override - String toString() => 'SlideshowConfig(repeat: $repeat, duration: $duration, look: $look, direction: $direction)'; +@freezed +abstract class SlideshowConfig with _$SlideshowConfig { + const factory SlideshowConfig({ + @Default(true) bool repeat, + @Default(5) int duration, + @Default(SlideshowLook.blurredBackground) SlideshowLook look, + @Default(SlideshowDirection.forward) SlideshowDirection direction, + }) = _SlideshowConfig; } diff --git a/mobile/lib/domain/models/config/theme_config.dart b/mobile/lib/domain/models/config/theme_config.dart index fa955c5d46..ac651a8062 100644 --- a/mobile/lib/domain/models/config/theme_config.dart +++ b/mobile/lib/domain/models/config/theme_config.dart @@ -1,44 +1,16 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/constants/colors.dart'; -class ThemeConfig { - final ThemeMode mode; - final ImmichColorPreset primaryColor; - final bool dynamicTheme; - final bool colorfulInterface; +part 'theme_config.freezed.dart'; - const ThemeConfig({ - this.mode = .system, - this.primaryColor = .indigo, - this.dynamicTheme = false, - this.colorfulInterface = true, - }); - - ThemeConfig copyWith({ - ThemeMode? mode, - ImmichColorPreset? primaryColor, - bool? dynamicTheme, - bool? colorfulInterface, - }) => .new( - mode: mode ?? this.mode, - primaryColor: primaryColor ?? this.primaryColor, - dynamicTheme: dynamicTheme ?? this.dynamicTheme, - colorfulInterface: colorfulInterface ?? this.colorfulInterface, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ThemeConfig && - other.mode == mode && - other.primaryColor == primaryColor && - other.dynamicTheme == dynamicTheme && - other.colorfulInterface == colorfulInterface); - - @override - int get hashCode => Object.hash(mode, primaryColor, dynamicTheme, colorfulInterface); - - @override - String toString() => - 'ThemeConfig(mode: $mode, primaryColor: $primaryColor, dynamicTheme: $dynamicTheme, colorfulInterface: $colorfulInterface)'; +@freezed +abstract class ThemeConfig with _$ThemeConfig { + const factory ThemeConfig({ + @Default(ThemeMode.system) ThemeMode mode, + @Default(ImmichColorPreset.indigo) ImmichColorPreset primaryColor, + @Default(false) bool dynamicTheme, + @Default(true) bool colorfulInterface, + }) = _ThemeConfig; } diff --git a/mobile/lib/domain/models/config/timeline_config.dart b/mobile/lib/domain/models/config/timeline_config.dart index 4b6b9d5625..c4681b0a62 100644 --- a/mobile/lib/domain/models/config/timeline_config.dart +++ b/mobile/lib/domain/models/config/timeline_config.dart @@ -1,30 +1,14 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; -class TimelineConfig { - final int tilesPerRow; - final GroupAssetsBy groupAssetsBy; - final bool storageIndicator; +part 'timeline_config.freezed.dart'; - const TimelineConfig({this.tilesPerRow = 4, this.groupAssetsBy = GroupAssetsBy.day, this.storageIndicator = true}); - - TimelineConfig copyWith({int? tilesPerRow, GroupAssetsBy? groupAssetsBy, bool? storageIndicator}) => TimelineConfig( - tilesPerRow: tilesPerRow ?? this.tilesPerRow, - groupAssetsBy: groupAssetsBy ?? this.groupAssetsBy, - storageIndicator: storageIndicator ?? this.storageIndicator, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is TimelineConfig && - other.tilesPerRow == tilesPerRow && - other.groupAssetsBy == groupAssetsBy && - other.storageIndicator == storageIndicator); - - @override - int get hashCode => Object.hash(tilesPerRow, groupAssetsBy, storageIndicator); - - @override - String toString() => - 'TimelineConfig(tilesPerRow: $tilesPerRow, groupAssetsBy: $groupAssetsBy, storageIndicator: $storageIndicator)'; +@freezed +abstract class TimelineConfig with _$TimelineConfig { + const factory TimelineConfig({ + @Default(4) int tilesPerRow, + @Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy, + @Default(true) bool storageIndicator, + }) = _TimelineConfig; } diff --git a/mobile/lib/domain/models/config/viewer_config.dart b/mobile/lib/domain/models/config/viewer_config.dart index 595f2bee5d..e799843034 100644 --- a/mobile/lib/domain/models/config/viewer_config.dart +++ b/mobile/lib/domain/models/config/viewer_config.dart @@ -1,37 +1,14 @@ -class ViewerConfig { - final bool loopVideo; - final bool loadOriginalVideo; - final bool autoPlayVideo; - final bool tapToNavigate; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const ViewerConfig({ - this.loopVideo = true, - this.loadOriginalVideo = false, - this.autoPlayVideo = true, - this.tapToNavigate = false, - }); +part 'viewer_config.freezed.dart'; - ViewerConfig copyWith({bool? loopVideo, bool? loadOriginalVideo, bool? autoPlayVideo, bool? tapToNavigate}) => - ViewerConfig( - loopVideo: loopVideo ?? this.loopVideo, - loadOriginalVideo: loadOriginalVideo ?? this.loadOriginalVideo, - autoPlayVideo: autoPlayVideo ?? this.autoPlayVideo, - tapToNavigate: tapToNavigate ?? this.tapToNavigate, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ViewerConfig && - other.loopVideo == loopVideo && - other.loadOriginalVideo == loadOriginalVideo && - other.autoPlayVideo == autoPlayVideo && - other.tapToNavigate == tapToNavigate); - - @override - int get hashCode => Object.hash(loopVideo, loadOriginalVideo, autoPlayVideo, tapToNavigate); - - @override - String toString() => - 'ViewerConfig(loopVideo: $loopVideo, loadOriginalVideo: $loadOriginalVideo, autoPlayVideo: $autoPlayVideo, tapToNavigate: $tapToNavigate)'; +@freezed +abstract class ViewerConfig with _$ViewerConfig { + const factory ViewerConfig({ + @Default(true) bool loopVideo, + @Default(false) bool loadOriginalVideo, + @Default(true) bool autoPlayVideo, + @Default(false) bool tapToNavigate, + }) = _ViewerConfig; } diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index 4284aef2ab..3536048ecc 100644 --- a/mobile/lib/domain/models/exif.model.dart +++ b/mobile/lib/domain/models/exif.model.dart @@ -1,30 +1,40 @@ -class ExifInfo { - final int? assetId; - final int? fileSize; - final String? description; - final bool isFlipped; - final String? orientation; - final String? timeZone; - final DateTime? dateTimeOriginal; - final int? rating; - final int? width; - final int? height; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - // GPS - final double? latitude; - final double? longitude; - final String? city; - final String? state; - final String? country; +part 'exif.model.freezed.dart'; - // Camera related - final String? make; - final String? model; - final String? lens; - final double? f; - final double? mm; - final int? iso; - final double? exposureSeconds; +@freezed +abstract class ExifInfo with _$ExifInfo { + const ExifInfo._(); + + const factory ExifInfo({ + int? assetId, + int? fileSize, + String? description, + @Default(false) bool isFlipped, + String? orientation, + String? timeZone, + DateTime? dateTimeOriginal, + int? rating, + int? width, + int? height, + + // GPS + double? latitude, + double? longitude, + String? city, + String? state, + String? country, + + // Camera related + String? make, + String? model, + String? lens, + double? f, + double? mm, + int? iso, + double? exposureSeconds, + }) = _ExifInfo; bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0; @@ -41,162 +51,4 @@ class ExifInfo { String get fNumber => f == null ? "" : f!.toStringAsFixed(1); String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3); - - const ExifInfo({ - this.assetId, - this.fileSize, - this.description, - this.orientation, - this.timeZone, - this.dateTimeOriginal, - this.rating, - this.width, - this.height, - this.isFlipped = false, - this.latitude, - this.longitude, - this.city, - this.state, - this.country, - this.make, - this.model, - this.lens, - this.f, - this.mm, - this.iso, - this.exposureSeconds, - }); - - @override - bool operator ==(covariant ExifInfo other) { - if (identical(this, other)) { - return true; - } - - return other.fileSize == fileSize && - other.description == description && - other.isFlipped == isFlipped && - other.orientation == orientation && - other.timeZone == timeZone && - other.dateTimeOriginal == dateTimeOriginal && - other.rating == rating && - other.width == width && - other.height == height && - other.latitude == latitude && - other.longitude == longitude && - other.city == city && - other.state == state && - other.country == country && - other.make == make && - other.model == model && - other.lens == lens && - other.f == f && - other.mm == mm && - other.iso == iso && - other.exposureSeconds == exposureSeconds && - other.assetId == assetId; - } - - @override - int get hashCode { - return fileSize.hashCode ^ - description.hashCode ^ - orientation.hashCode ^ - isFlipped.hashCode ^ - timeZone.hashCode ^ - dateTimeOriginal.hashCode ^ - rating.hashCode ^ - width.hashCode ^ - height.hashCode ^ - latitude.hashCode ^ - longitude.hashCode ^ - city.hashCode ^ - state.hashCode ^ - country.hashCode ^ - make.hashCode ^ - model.hashCode ^ - lens.hashCode ^ - f.hashCode ^ - mm.hashCode ^ - iso.hashCode ^ - exposureSeconds.hashCode ^ - assetId.hashCode; - } - - @override - String toString() { - return '''{ -fileSize: ${fileSize ?? 'NA'}, -description: ${description ?? 'NA'}, -orientation: ${orientation ?? 'NA'}, -isFlipped: $isFlipped, -timeZone: ${timeZone ?? 'NA'}, -dateTimeOriginal: ${dateTimeOriginal ?? 'NA'}, -rating: ${rating ?? 'NA'}, -width: ${width ?? 'NA'}, -height: ${height ?? 'NA'}, -latitude: ${latitude ?? 'NA'}, -longitude: ${longitude ?? 'NA'}, -city: ${city ?? 'NA'}, -state: ${state ?? 'NA'}, -country: ${country ?? ''}, -make: ${make ?? 'NA'}, -model: ${model ?? 'NA'}, -lens: ${lens ?? 'NA'}, -f: ${f ?? 'NA'}, -mm: ${mm ?? ''}, -iso: ${iso ?? 'NA'}, -exposureSeconds: ${exposureSeconds ?? 'NA'}, -}'''; - } - - ExifInfo copyWith({ - int? assetId, - int? fileSize, - String? description, - String? orientation, - String? timeZone, - DateTime? dateTimeOriginal, - int? rating, - int? width, - int? height, - double? latitude, - double? longitude, - String? city, - String? state, - String? country, - bool? isFlipped, - String? make, - String? model, - String? lens, - double? f, - double? mm, - int? iso, - double? exposureSeconds, - }) { - return ExifInfo( - assetId: assetId ?? this.assetId, - fileSize: fileSize ?? this.fileSize, - description: description ?? this.description, - orientation: orientation ?? this.orientation, - timeZone: timeZone ?? this.timeZone, - dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, - rating: rating ?? this.rating, - width: width ?? this.width, - height: height ?? this.height, - isFlipped: isFlipped ?? this.isFlipped, - latitude: latitude ?? this.latitude, - longitude: longitude ?? this.longitude, - city: city ?? this.city, - state: state ?? this.state, - country: country ?? this.country, - make: make ?? this.make, - model: model ?? this.model, - lens: lens ?? this.lens, - f: f ?? this.f, - mm: mm ?? this.mm, - iso: iso ?? this.iso, - exposureSeconds: exposureSeconds ?? this.exposureSeconds, - ); - } } diff --git a/mobile/lib/domain/models/feature_message.model.dart b/mobile/lib/domain/models/feature_message.model.dart index b3c9c459d3..817076c0e9 100644 --- a/mobile/lib/domain/models/feature_message.model.dart +++ b/mobile/lib/domain/models/feature_message.model.dart @@ -1,21 +1,40 @@ import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/utils/semver.dart'; -class FeatureHighlight { +enum FeatureHighlight { + shareQuality(image: 'assets/feature_message/share_quality.webp'), + slideshow(image: 'assets/feature_message/slideshow.webp'), + recentlyAdded(image: 'assets/feature_message/recently_added.webp'), + ocr(image: 'assets/feature_message/ocr.webp'), + openInImmich(image: 'assets/feature_message/open_in_immich.webp', platform: [.android]), + uploadToAlbum(); + /// Asset path of the feature screenshot, or null to show a placeholder. final String? image; - final String titleKey; - final String bodyKey; final List platform; - const FeatureHighlight({ - this.image, - required this.titleKey, - required this.bodyKey, - this.platform = const [.iOS, .android], - }); + const FeatureHighlight({this.image, this.platform = const [.iOS, .android]}); bool get isVisibleOnCurrentPlatform => platform.contains(defaultTargetPlatform); + + String title(Translations t) => switch (this) { + FeatureHighlight.shareQuality => t.share_quality_title, + FeatureHighlight.slideshow => t.slideshow_title, + FeatureHighlight.recentlyAdded => t.recently_added_title, + FeatureHighlight.ocr => t.ocr_title, + FeatureHighlight.openInImmich => t.open_in_immich_title, + FeatureHighlight.uploadToAlbum => t.upload_to_album_title, + }; + + String body(Translations t) => switch (this) { + FeatureHighlight.shareQuality => t.share_quality_body, + FeatureHighlight.slideshow => t.slideshow_body, + FeatureHighlight.recentlyAdded => t.recently_added_body, + FeatureHighlight.ocr => t.ocr_body, + FeatureHighlight.openInImmich => t.open_in_immich_body, + FeatureHighlight.uploadToAlbum => t.upload_to_album_body, + }; } /// The release this batch of highlights was authored for. Content-defined: @@ -24,31 +43,4 @@ const featureMessageRelease = SemVer(major: 3, minor: 0, patch: 0); /// Highlights relevant to the current platform. List get visibleFeatureMessageHighlights => - featureMessageHighlights.where((h) => h.isVisibleOnCurrentPlatform).toList(); - -const List featureMessageHighlights = [ - FeatureHighlight( - image: 'assets/feature_message/share_quality.webp', - titleKey: 'share_quality_title', - bodyKey: 'share_quality_body', - ), - FeatureHighlight( - image: 'assets/feature_message/slideshow.webp', - titleKey: 'slideshow_title', - bodyKey: 'slideshow_body', - ), - FeatureHighlight( - image: 'assets/feature_message/recently_added.webp', - titleKey: 'recently_added_title', - bodyKey: 'recently_added_body', - ), - - FeatureHighlight(image: 'assets/feature_message/ocr.webp', titleKey: 'ocr_title', bodyKey: 'ocr_body'), - FeatureHighlight( - image: 'assets/feature_message/open_in_immich.webp', - titleKey: 'open_in_immich_title', - bodyKey: 'open_in_immich_body', - platform: [.android], - ), - FeatureHighlight(titleKey: 'upload_to_album_title', bodyKey: 'upload_to_album_body'), -]; + FeatureHighlight.values.where((h) => h.isVisibleOnCurrentPlatform).toList(); diff --git a/mobile/lib/domain/models/log.model.dart b/mobile/lib/domain/models/log.model.dart index bed1729f9d..72267bcb5c 100644 --- a/mobile/lib/domain/models/log.model.dart +++ b/mobile/lib/domain/models/log.model.dart @@ -1,51 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'log.model.freezed.dart'; + /// Log levels according to dart logging [Level] enum LogLevel { all, finest, finer, fine, config, info, warning, severe, shout, off } -class LogMessage { - final String message; - final LogLevel level; - final DateTime createdAt; - final String? logger; - final String? error; - final String? stack; - - const LogMessage({ - required this.message, - required this.level, - required this.createdAt, - this.logger, - this.error, - this.stack, - }); - - @override - bool operator ==(covariant LogMessage other) { - if (identical(this, other)) { - return true; - } - - return other.message == message && - other.level == level && - other.createdAt == createdAt && - other.logger == logger && - other.error == error && - other.stack == stack; - } - - @override - int get hashCode { - return message.hashCode ^ level.hashCode ^ createdAt.hashCode ^ logger.hashCode ^ error.hashCode ^ stack.hashCode; - } - - @override - String toString() { - return '''LogMessage: { -message: $message, -level: $level, -createdAt: $createdAt, -logger: ${logger ?? ''}, -error: ${error ?? ''}, -stack: ${stack ?? ''}, -}'''; - } +@freezed +abstract class LogMessage with _$LogMessage { + const factory LogMessage({ + required String message, + required LogLevel level, + required DateTime createdAt, + String? logger, + String? error, + String? stack, + }) = _LogMessage; } diff --git a/mobile/lib/domain/models/map.model.dart b/mobile/lib/domain/models/map.model.dart index b55f176bfd..52f6fe8afd 100644 --- a/mobile/lib/domain/models/map.model.dart +++ b/mobile/lib/domain/models/map.model.dart @@ -1,20 +1,9 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class Marker { - final LatLng location; - final String assetId; +part 'map.model.freezed.dart'; - const Marker({required this.location, required this.assetId}); - - @override - bool operator ==(covariant Marker other) { - if (identical(this, other)) { - return true; - } - - return other.location == location && other.assetId == assetId; - } - - @override - int get hashCode => location.hashCode ^ assetId.hashCode; +@freezed +abstract class Marker with _$Marker { + const factory Marker({required LatLng location, required String assetId}) = _Marker; } diff --git a/mobile/lib/domain/models/memory.model.dart b/mobile/lib/domain/models/memory.model.dart index e786ca18b1..1c556f382f 100644 --- a/mobile/lib/domain/models/memory.model.dart +++ b/mobile/lib/domain/models/memory.model.dart @@ -1,22 +1,21 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; -import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +part 'memory.model.freezed.dart'; + +// TODO(agg23): Remove enum suffix enum MemoryTypeEnum { // do not change this order! onThisDay, } -class MemoryData { - final int year; +@Freezed(fromJson: false, toJson: false) +abstract class MemoryData with _$MemoryData { + const MemoryData._(); - const MemoryData({required this.year}); - - MemoryData copyWith({int? year}) { - return MemoryData(year: year ?? this.year); - } + const factory MemoryData({required int year}) = _MemoryData; Map toMap() { return {'year': year}; @@ -29,144 +28,25 @@ class MemoryData { String toJson() => json.encode(toMap()); factory MemoryData.fromJson(String source) => MemoryData.fromMap(json.decode(source) as Map); - - @override - String toString() => 'MemoryData(year: $year)'; - - @override - bool operator ==(covariant MemoryData other) { - if (identical(this, other)) { - return true; - } - - return other.year == year; - } - - @override - int get hashCode => year.hashCode; } // Model for a memory stored in the server -class DriftMemory { - final String id; - final DateTime createdAt; - final DateTime updatedAt; - final DateTime? deletedAt; - final String ownerId; - - // enum - final MemoryTypeEnum type; - final MemoryData data; - final bool isSaved; - final DateTime memoryAt; - final DateTime? seenAt; - final DateTime? showAt; - final DateTime? hideAt; - final List assets; - - const DriftMemory({ - required this.id, - required this.createdAt, - required this.updatedAt, - this.deletedAt, - required this.ownerId, - required this.type, - required this.data, - required this.isSaved, - required this.memoryAt, - this.seenAt, - this.showAt, - this.hideAt, - required this.assets, - }); - - DriftMemory copyWith({ - String? id, - DateTime? createdAt, - DateTime? updatedAt, +// TODO(agg23): DriftMemoryRepository currently mutates `assets` +@Freezed(makeCollectionsUnmodifiable: false) +abstract class DriftMemory with _$DriftMemory { + const factory DriftMemory({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, DateTime? deletedAt, - String? ownerId, - MemoryTypeEnum? type, - MemoryData? data, - bool? isSaved, - DateTime? memoryAt, + required String ownerId, + required MemoryTypeEnum type, + required MemoryData data, + required bool isSaved, + required DateTime memoryAt, DateTime? seenAt, DateTime? showAt, DateTime? hideAt, - List? assets, - }) { - return DriftMemory( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - deletedAt: deletedAt ?? this.deletedAt, - ownerId: ownerId ?? this.ownerId, - type: type ?? this.type, - data: data ?? this.data, - isSaved: isSaved ?? this.isSaved, - memoryAt: memoryAt ?? this.memoryAt, - seenAt: seenAt ?? this.seenAt, - showAt: showAt ?? this.showAt, - hideAt: hideAt ?? this.hideAt, - assets: assets ?? this.assets, - ); - } - - @override - String toString() { - return '''Memory { - id: $id, - createdAt: $createdAt, - updatedAt: $updatedAt, - deletedAt: ${deletedAt ?? ""}, - ownerId: $ownerId, - type: $type, - data: $data, - isSaved: $isSaved, - memoryAt: $memoryAt, - seenAt: ${seenAt ?? ""}, - showAt: ${showAt ?? ""}, - hideAt: ${hideAt ?? ""}, - assets: $assets -}'''; - } - - @override - bool operator ==(covariant DriftMemory other) { - if (identical(this, other)) { - return true; - } - final listEquals = const DeepCollectionEquality().equals; - - return other.id == id && - other.createdAt == createdAt && - other.updatedAt == updatedAt && - other.deletedAt == deletedAt && - other.ownerId == ownerId && - other.type == type && - other.data == data && - other.isSaved == isSaved && - other.memoryAt == memoryAt && - other.seenAt == seenAt && - other.showAt == showAt && - other.hideAt == hideAt && - listEquals(other.assets, assets); - } - - @override - int get hashCode { - return id.hashCode ^ - createdAt.hashCode ^ - updatedAt.hashCode ^ - deletedAt.hashCode ^ - ownerId.hashCode ^ - type.hashCode ^ - data.hashCode ^ - isSaved.hashCode ^ - memoryAt.hashCode ^ - seenAt.hashCode ^ - showAt.hashCode ^ - hideAt.hashCode ^ - assets.hashCode; - } + required List assets, + }) = _DriftMemory; } diff --git a/mobile/lib/domain/models/ocr.model.dart b/mobile/lib/domain/models/ocr.model.dart index 403c06d44e..bfb398bc79 100644 --- a/mobile/lib/domain/models/ocr.model.dart +++ b/mobile/lib/domain/models/ocr.model.dart @@ -1,128 +1,24 @@ -class Ocr { - final String id; - final String assetId; - final double x1; - final double y1; - final double x2; - final double y2; - final double x3; - final double y3; - final double x4; - final double y4; - final double boxScore; - final double textScore; - final String text; - final bool isVisible; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const Ocr({ - required this.id, - required this.assetId, - required this.x1, - required this.y1, - required this.x2, - required this.y2, - required this.x3, - required this.y3, - required this.x4, - required this.y4, - required this.boxScore, - required this.textScore, - required this.text, - required this.isVisible, - }); +part 'ocr.model.freezed.dart'; - Ocr copyWith({ - String? id, - String? assetId, - double? x1, - double? y1, - double? x2, - double? y2, - double? x3, - double? y3, - double? x4, - double? y4, - double? boxScore, - double? textScore, - String? text, - bool? isVisible, - }) { - return Ocr( - id: id ?? this.id, - assetId: assetId ?? this.assetId, - x1: x1 ?? this.x1, - y1: y1 ?? this.y1, - x2: x2 ?? this.x2, - y2: y2 ?? this.y2, - x3: x3 ?? this.x3, - y3: y3 ?? this.y3, - x4: x4 ?? this.x4, - y4: y4 ?? this.y4, - boxScore: boxScore ?? this.boxScore, - textScore: textScore ?? this.textScore, - text: text ?? this.text, - isVisible: isVisible ?? this.isVisible, - ); - } - - @override - String toString() { - return '''Ocr { - id: $id, - assetId: $assetId, - x1: $x1, - y1: $y1, - x2: $x2, - y2: $y2, - x3: $x3, - y3: $y3, - x4: $x4, - y4: $y4, - boxScore: $boxScore, - textScore: $textScore, - text: $text, - isVisible: $isVisible - }'''; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is Ocr && - other.id == id && - other.assetId == assetId && - other.x1 == x1 && - other.y1 == y1 && - other.x2 == x2 && - other.y2 == y2 && - other.x3 == x3 && - other.y3 == y3 && - other.x4 == x4 && - other.y4 == y4 && - other.boxScore == boxScore && - other.textScore == textScore && - other.text == text && - other.isVisible == isVisible; - } - - @override - int get hashCode { - return id.hashCode ^ - assetId.hashCode ^ - x1.hashCode ^ - y1.hashCode ^ - x2.hashCode ^ - y2.hashCode ^ - x3.hashCode ^ - y3.hashCode ^ - x4.hashCode ^ - y4.hashCode ^ - boxScore.hashCode ^ - textScore.hashCode ^ - text.hashCode ^ - isVisible.hashCode; - } +@freezed +abstract class Ocr with _$Ocr { + const factory Ocr({ + required String id, + required String assetId, + required double x1, + required double y1, + required double x2, + required double y2, + required double x3, + required double y3, + required double x4, + required double y4, + required double boxScore, + required double textScore, + required String text, + required bool isVisible, + }) = _Ocr; } diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart index c7cdcff3af..94ead6a2e9 100644 --- a/mobile/lib/domain/models/person.model.dart +++ b/mobile/lib/domain/models/person.model.dart @@ -1,194 +1,9 @@ -import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; -// TODO: Remove PersonDto once Isar is removed -class PersonDto { - const PersonDto({ - required this.id, - this.birthDate, - required this.isHidden, - required this.name, - required this.thumbnailPath, - this.updatedAt, - }); +part 'person.model.freezed.dart'; - final String id; - final DateTime? birthDate; - final bool isHidden; - final String name; - final String thumbnailPath; - final DateTime? updatedAt; - - @override - String toString() { - return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)'; - } - - PersonDto copyWith({ - String? id, - DateTime? birthDate, - bool? isHidden, - String? name, - String? thumbnailPath, - DateTime? updatedAt, - }) { - return PersonDto( - id: id ?? this.id, - birthDate: birthDate ?? this.birthDate, - isHidden: isHidden ?? this.isHidden, - name: name ?? this.name, - thumbnailPath: thumbnailPath ?? this.thumbnailPath, - updatedAt: updatedAt ?? this.updatedAt, - ); - } - - Map toMap() { - return { - 'id': id, - 'birthDate': birthDate?.millisecondsSinceEpoch, - 'isHidden': isHidden, - 'name': name, - 'thumbnailPath': thumbnailPath, - 'updatedAt': updatedAt?.millisecondsSinceEpoch, - }; - } - - factory PersonDto.fromMap(Map map) { - return PersonDto( - id: map['id'] as String, - birthDate: map['birthDate'] != null ? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int) : null, - isHidden: map['isHidden'] as bool, - name: map['name'] as String, - thumbnailPath: map['thumbnailPath'] as String, - updatedAt: map['updatedAt'] != null ? DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int) : null, - ); - } - - String toJson() => json.encode(toMap()); - - factory PersonDto.fromJson(String source) => PersonDto.fromMap(json.decode(source) as Map); - - @override - bool operator ==(covariant PersonDto other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.birthDate == birthDate && - other.isHidden == isHidden && - other.name == name && - other.thumbnailPath == thumbnailPath && - other.updatedAt == updatedAt; - } - - @override - int get hashCode { - return id.hashCode ^ - birthDate.hashCode ^ - isHidden.hashCode ^ - name.hashCode ^ - thumbnailPath.hashCode ^ - updatedAt.hashCode; - } -} - -// Model for a person stored in the server -class DriftPerson { - final String id; - final DateTime createdAt; - final DateTime updatedAt; - final String ownerId; - final String name; - final String? faceAssetId; - final bool isFavorite; - final bool isHidden; - final String? color; - final DateTime? birthDate; - - const DriftPerson({ - required this.id, - required this.createdAt, - required this.updatedAt, - required this.ownerId, - required this.name, - this.faceAssetId, - required this.isFavorite, - required this.isHidden, - required this.color, - this.birthDate, - }); - - DriftPerson copyWith({ - String? id, - DateTime? createdAt, - DateTime? updatedAt, - String? ownerId, - String? name, - String? faceAssetId, - bool? isFavorite, - bool? isHidden, - String? color, - DateTime? birthDate, - }) { - return DriftPerson( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ownerId: ownerId ?? this.ownerId, - name: name ?? this.name, - faceAssetId: faceAssetId ?? this.faceAssetId, - isFavorite: isFavorite ?? this.isFavorite, - isHidden: isHidden ?? this.isHidden, - color: color ?? this.color, - birthDate: birthDate ?? this.birthDate, - ); - } - - @override - String toString() { - return '''Person { - id: $id, - createdAt: $createdAt, - updatedAt: $updatedAt, - ownerId: $ownerId, - name: $name, - faceAssetId: ${faceAssetId ?? ""}, - isFavorite: $isFavorite, - isHidden: $isHidden, - color: ${color ?? ""}, - birthDate: ${birthDate ?? ""} -}'''; - } - - @override - bool operator ==(covariant DriftPerson other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.createdAt == createdAt && - other.updatedAt == updatedAt && - other.ownerId == ownerId && - other.name == name && - other.faceAssetId == faceAssetId && - other.isFavorite == isFavorite && - other.isHidden == isHidden && - other.color == color && - other.birthDate == birthDate; - } - - @override - int get hashCode { - return id.hashCode ^ - createdAt.hashCode ^ - updatedAt.hashCode ^ - ownerId.hashCode ^ - name.hashCode ^ - faceAssetId.hashCode ^ - isFavorite.hashCode ^ - isHidden.hashCode ^ - color.hashCode ^ - birthDate.hashCode; - } +@freezed +abstract class Person with _$Person { + const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person; } diff --git a/mobile/lib/domain/models/search_result.model.dart b/mobile/lib/domain/models/search_result.model.dart index 6a782e2f37..11409e64a2 100644 --- a/mobile/lib/domain/models/search_result.model.dart +++ b/mobile/lib/domain/models/search_result.model.dart @@ -1,25 +1,15 @@ -import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -class SearchResult { - final List assets; - final int? nextPage; +part 'search_result.model.freezed.dart'; - const SearchResult({required this.assets, this.nextPage}); +@Freezed(toStringOverride: false) +abstract class SearchResult with _$SearchResult { + const SearchResult._(); + const factory SearchResult({required List assets, int? nextPage}) = _SearchResult; + + // Explicitly don't log results, only attributes @override String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)'; - - @override - bool operator ==(covariant SearchResult other) { - if (identical(this, other)) { - return true; - } - final listEquals = const DeepCollectionEquality().equals; - - return listEquals(other.assets, assets) && other.nextPage == nextPage; - } - - @override - int get hashCode => assets.hashCode ^ nextPage.hashCode; } diff --git a/mobile/lib/domain/models/server_capability.model.dart b/mobile/lib/domain/models/server_capability.model.dart new file mode 100644 index 0000000000..ece9850594 --- /dev/null +++ b/mobile/lib/domain/models/server_capability.model.dart @@ -0,0 +1,23 @@ +import 'package:immich_mobile/utils/semver.dart'; + +enum ServerCapability { + // Feature Support + cloudIdMetadata(SemVer(major: 2, minor: 4, patch: 0)), + bulkCloudIdMetadata(SemVer(major: 2, minor: 5, patch: 0)), + assetEdits(SemVer(major: 2, minor: 6, patch: 0)), + assetFacesV2(SemVer(major: 2, minor: 6, patch: 0)), + syncV2(SemVer(major: 3, minor: 0, patch: 0)), + assetOcr(SemVer(major: 3, minor: 0, patch: 0)), + + // Migrations + assetPayloadChange20260128(SemVer(major: 2, minor: 5, patch: 0)), + assetPayloadChange20260597(SemVer(major: 2, minor: 7, patch: 6)); + + const ServerCapability(this.minVersion); + + final SemVer minVersion; +} + +extension ServerCapabilitySupport on SemVer { + bool supports(ServerCapability capability) => this >= capability.minVersion; +} diff --git a/mobile/lib/domain/models/stack.model.dart b/mobile/lib/domain/models/stack.model.dart index f17f5788c9..4e88a02c6c 100644 --- a/mobile/lib/domain/models/stack.model.dart +++ b/mobile/lib/domain/models/stack.model.dart @@ -1,57 +1,18 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'stack.model.freezed.dart'; + // Model for a stack stored in the server -class Stack { - final String id; - final DateTime createdAt; - final DateTime updatedAt; - final String ownerId; - final String primaryAssetId; - - const Stack({ - required this.id, - required this.createdAt, - required this.updatedAt, - required this.ownerId, - required this.primaryAssetId, - }); - - Stack copyWith({String? id, DateTime? createdAt, DateTime? updatedAt, String? ownerId, String? primaryAssetId}) { - return Stack( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ownerId: ownerId ?? this.ownerId, - primaryAssetId: primaryAssetId ?? this.primaryAssetId, - ); - } - - @override - String toString() { - return '''Stack { - id: $id, - createdAt: $createdAt, - updatedAt: $updatedAt, - ownerId: $ownerId, - primaryAssetId: $primaryAssetId -}'''; - } - - @override - bool operator ==(covariant Stack other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.createdAt == createdAt && - other.updatedAt == updatedAt && - other.ownerId == ownerId && - other.primaryAssetId == primaryAssetId; - } - - @override - int get hashCode { - return id.hashCode ^ createdAt.hashCode ^ updatedAt.hashCode ^ ownerId.hashCode ^ primaryAssetId.hashCode; - } +@freezed +abstract class Stack with _$Stack { + const factory Stack({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + required String ownerId, + required String primaryAssetId, + }) = _Stack; } class StackResponse { diff --git a/mobile/lib/domain/models/tag.model.dart b/mobile/lib/domain/models/tag.model.dart index ba9aef02ee..d38931a285 100644 --- a/mobile/lib/domain/models/tag.model.dart +++ b/mobile/lib/domain/models/tag.model.dart @@ -1,29 +1,11 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:openapi/api.dart'; -class Tag { - final String id; - final String value; +part 'tag.model.freezed.dart'; - const Tag({required this.id, required this.value}); - - @override - String toString() { - return 'Tag(id: $id, value: $value)'; - } - - @override - bool operator ==(covariant Tag other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && other.value == value; - } - - @override - int get hashCode { - return id.hashCode ^ value.hashCode; - } +@freezed +abstract class Tag with _$Tag { + const factory Tag({required String id, required String value}) = _Tag; static Tag fromDto(TagResponseDto dto) { return Tag(id: dto.id, value: dto.value); diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart index d1a7fc5546..181fddf581 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -1,7 +1,9 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first -import 'dart:convert'; import 'dart:ui'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'user.model.freezed.dart'; + enum AvatarColor { // do not change this order or reuse indices for other purposes, adding is OK primary("primary"), @@ -164,78 +166,15 @@ profileChangedAt: $profileChangedAt quotaUsageInBytes.hashCode; } -class PartnerUserDto { - final String id; - final String email; - final String name; - final bool inTimeline; - - final String? profileImagePath; - - const PartnerUserDto({ - required this.id, - required this.email, - required this.name, - required this.inTimeline, - this.profileImagePath, - }); - - PartnerUserDto copyWith({String? id, String? email, String? name, bool? inTimeline, String? profileImagePath}) { - return PartnerUserDto( - id: id ?? this.id, - email: email ?? this.email, - name: name ?? this.name, - inTimeline: inTimeline ?? this.inTimeline, - profileImagePath: profileImagePath ?? this.profileImagePath, - ); - } - - Map toMap() { - return { - 'id': id, - 'email': email, - 'name': name, - 'inTimeline': inTimeline, - 'profileImagePath': profileImagePath, - }; - } - - factory PartnerUserDto.fromMap(Map map) { - return PartnerUserDto( - id: map['id'] as String, - email: map['email'] as String, - name: map['name'] as String, - inTimeline: map['inTimeline'] as bool, - profileImagePath: map['profileImagePath'] != null ? map['profileImagePath'] as String : null, - ); - } - - String toJson() => json.encode(toMap()); - - factory PartnerUserDto.fromJson(String source) => PartnerUserDto.fromMap(json.decode(source) as Map); - - @override - String toString() { - return 'PartnerUserDto(id: $id, email: $email, name: $name, inTimeline: $inTimeline, profileImagePath: $profileImagePath)'; - } - - @override - bool operator ==(covariant PartnerUserDto other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.email == email && - other.name == name && - other.inTimeline == inTimeline && - other.profileImagePath == profileImagePath; - } - - @override - int get hashCode { - return id.hashCode ^ email.hashCode ^ name.hashCode ^ inTimeline.hashCode ^ profileImagePath.hashCode; - } +@freezed +abstract class PartnerUserDto with _$PartnerUserDto { + const factory PartnerUserDto({ + required String id, + required String email, + required String name, + required bool inTimeline, + String? profileImagePath, + }) = _PartnerUserDto; } class User { diff --git a/mobile/lib/domain/models/user_metadata.model.dart b/mobile/lib/domain/models/user_metadata.model.dart index 0e702ba868..2b1da0a809 100644 --- a/mobile/lib/domain/models/user_metadata.model.dart +++ b/mobile/lib/domain/models/user_metadata.model.dart @@ -1,5 +1,9 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +part 'user_metadata.model.freezed.dart'; + enum UserMetadataKey { // do not change this order! onboarding, @@ -7,105 +11,32 @@ enum UserMetadataKey { license, } -class Onboarding { - final bool isOnboarded; +@freezed +abstract class Onboarding with _$Onboarding { + const Onboarding._(); - const Onboarding({required this.isOnboarded}); - - Onboarding copyWith({bool? isOnboarded}) { - return Onboarding(isOnboarded: isOnboarded ?? this.isOnboarded); - } - - Map toMap() { - final onboarding = {}; - onboarding["isOnboarded"] = isOnboarded; - return onboarding; - } + const factory Onboarding({required bool isOnboarded}) = _Onboarding; factory Onboarding.fromMap(Map map) { return Onboarding(isOnboarded: map["isOnboarded"]! as bool); } - - @override - String toString() { - return '''Onboarding { -isOnboarded: $isOnboarded, -}'''; - } - - @override - bool operator ==(covariant Onboarding other) { - if (identical(this, other)) { - return true; - } - - return isOnboarded == other.isOnboarded; - } - - @override - int get hashCode => isOnboarded.hashCode; } -class Preferences { - final bool foldersEnabled; - final bool memoriesEnabled; - final bool peopleEnabled; - final bool ratingsEnabled; - final bool sharedLinksEnabled; - final bool tagsEnabled; - final AvatarColor userAvatarColor; - final bool showSupportBadge; - final int minimumFaces; +@freezed +abstract class Preferences with _$Preferences { + const Preferences._(); - const Preferences({ - this.foldersEnabled = false, - this.memoriesEnabled = true, - this.peopleEnabled = true, - this.ratingsEnabled = false, - this.sharedLinksEnabled = true, - this.tagsEnabled = false, - this.userAvatarColor = AvatarColor.primary, - this.showSupportBadge = true, - this.minimumFaces = 3, - }); - - Preferences copyWith({ - bool? foldersEnabled, - bool? memoriesEnabled, - bool? peopleEnabled, - bool? ratingsEnabled, - bool? sharedLinksEnabled, - bool? tagsEnabled, - AvatarColor? userAvatarColor, - bool? showSupportBadge, - int? minimumFaces, - }) { - return Preferences( - foldersEnabled: foldersEnabled ?? this.foldersEnabled, - memoriesEnabled: memoriesEnabled ?? this.memoriesEnabled, - peopleEnabled: peopleEnabled ?? this.peopleEnabled, - ratingsEnabled: ratingsEnabled ?? this.ratingsEnabled, - sharedLinksEnabled: sharedLinksEnabled ?? this.sharedLinksEnabled, - tagsEnabled: tagsEnabled ?? this.tagsEnabled, - userAvatarColor: userAvatarColor ?? this.userAvatarColor, - showSupportBadge: showSupportBadge ?? this.showSupportBadge, - minimumFaces: minimumFaces ?? this.minimumFaces, - ); - } - - Map toMap() { - final preferences = {}; - preferences["folders-Enabled"] = foldersEnabled; - preferences["memories-Enabled"] = memoriesEnabled; - preferences["people-Enabled"] = peopleEnabled; - preferences["ratings-Enabled"] = ratingsEnabled; - preferences["sharedLinks-Enabled"] = sharedLinksEnabled; - preferences["tags-Enabled"] = tagsEnabled; - preferences["avatar-Color"] = userAvatarColor.value; - preferences["purchase-ShowSupportBadge"] = showSupportBadge; - preferences["minimumFaces"] = minimumFaces; - return preferences; - } + const factory Preferences({ + @Default(false) bool foldersEnabled, + @Default(true) bool memoriesEnabled, + @Default(true) bool peopleEnabled, + @Default(false) bool ratingsEnabled, + @Default(true) bool sharedLinksEnabled, + @Default(false) bool tagsEnabled, + @Default(AvatarColor.primary) AvatarColor userAvatarColor, + @Default(true) bool showSupportBadge, + @Default(3) int minimumFaces, + }) = _Preferences; factory Preferences.fromMap(Map map) { return Preferences( @@ -123,75 +54,14 @@ class Preferences { minimumFaces: (map["people"] as Map?)?["minimumFaces"] as int? ?? 3, ); } - - @override - String toString() { - return '''Preferences: { -foldersEnabled: $foldersEnabled, -memoriesEnabled: $memoriesEnabled, -peopleEnabled: $peopleEnabled, -ratingsEnabled: $ratingsEnabled, -sharedLinksEnabled: $sharedLinksEnabled, -tagsEnabled: $tagsEnabled, -userAvatarColor: $userAvatarColor, -showSupportBadge: $showSupportBadge, -minimumFaces: $minimumFaces, -}'''; - } - - @override - bool operator ==(covariant Preferences other) { - if (identical(this, other)) { - return true; - } - - return other.foldersEnabled == foldersEnabled && - other.memoriesEnabled == memoriesEnabled && - other.peopleEnabled == peopleEnabled && - other.ratingsEnabled == ratingsEnabled && - other.sharedLinksEnabled == sharedLinksEnabled && - other.tagsEnabled == tagsEnabled && - other.userAvatarColor == userAvatarColor && - other.showSupportBadge == showSupportBadge && - other.minimumFaces == minimumFaces; - } - - @override - int get hashCode { - return foldersEnabled.hashCode ^ - memoriesEnabled.hashCode ^ - peopleEnabled.hashCode ^ - ratingsEnabled.hashCode ^ - sharedLinksEnabled.hashCode ^ - tagsEnabled.hashCode ^ - userAvatarColor.hashCode ^ - showSupportBadge.hashCode ^ - minimumFaces.hashCode; - } } -class License { - final DateTime activatedAt; - final String activationKey; - final String licenseKey; +@freezed +abstract class License with _$License { + const License._(); - const License({required this.activatedAt, required this.activationKey, required this.licenseKey}); - - License copyWith({DateTime? activatedAt, String? activationKey, String? licenseKey}) { - return License( - activatedAt: activatedAt ?? this.activatedAt, - activationKey: activationKey ?? this.activationKey, - licenseKey: licenseKey ?? this.licenseKey, - ); - } - - Map toMap() { - final license = {}; - license["activatedAt"] = activatedAt; - license["activationKey"] = activationKey; - license["licenseKey"] = licenseKey; - return license; - } + const factory License({required DateTime activatedAt, required String activationKey, required String licenseKey}) = + _License; factory License.fromMap(Map map) { return License( @@ -200,85 +70,20 @@ class License { licenseKey: map["licenseKey"]! as String, ); } - - @override - String toString() { - return '''License { -activatedAt: $activatedAt, -activationKey: $activationKey, -licenseKey: $licenseKey, -}'''; - } - - @override - bool operator ==(covariant License other) { - if (identical(this, other)) { - return true; - } - - return activatedAt == other.activatedAt && activationKey == other.activationKey && licenseKey == other.licenseKey; - } - - @override - int get hashCode => activatedAt.hashCode ^ activationKey.hashCode ^ licenseKey.hashCode; } // Model for a user metadata stored in the server -class UserMetadata { - final String userId; - final UserMetadataKey key; - final Onboarding? onboarding; - final Preferences? preferences; - final License? license; - - const UserMetadata({required this.userId, required this.key, this.onboarding, this.preferences, this.license}) - : assert( - onboarding != null || preferences != null || license != null, - 'One of onboarding, preferences and license must be provided', - ); - - UserMetadata copyWith({ - String? userId, - UserMetadataKey? key, +@freezed +abstract class UserMetadata with _$UserMetadata { + @Assert( + 'onboarding != null || preferences != null || license != null', + 'One of onboarding, preferences and license must be provided', + ) + const factory UserMetadata({ + required String userId, + required UserMetadataKey key, Onboarding? onboarding, Preferences? preferences, License? license, - }) { - return UserMetadata( - userId: userId ?? this.userId, - key: key ?? this.key, - onboarding: onboarding ?? this.onboarding, - preferences: preferences ?? this.preferences, - license: license ?? this.license, - ); - } - - @override - String toString() { - return '''UserMetadata: { -userId: $userId, -key: $key, -onboarding: ${onboarding ?? ""}, -preferences: ${preferences ?? ""}, -license: ${license ?? ""}, -}'''; - } - - @override - bool operator ==(covariant UserMetadata other) { - if (identical(this, other)) { - return true; - } - - return other.userId == userId && - other.key == key && - other.onboarding == onboarding && - other.preferences == preferences && - other.license == license; - } - - @override - int get hashCode { - return userId.hashCode ^ key.hashCode ^ onboarding.hashCode ^ preferences.hashCode ^ license.hashCode; - } + }) = _UserMetadata; } diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 59f41f1e51..0032403348 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -17,10 +17,10 @@ import 'package:maplibre_gl/maplibre_gl.dart'; class AssetService { final RemoteAssetRepository _remoteRepository; final RemoteExifRepository _exifRepository; - final DriftLocalAssetRepository _localRepository; + final LocalAssetRepository _localRepository; final AssetApiRepository _apiRepository; final AssetMediaRepository _mediaRepository; - final DriftTrashedLocalAssetRepository _trashedLocalRepository; + final TrashedLocalAssetRepository _trashedLocalRepository; const AssetService({ required this._remoteRepository, @@ -136,13 +136,13 @@ class AssetService { location: location, dateTimeOriginal: dateTime, ); - await _remoteRepository.update( + await _remoteRepository.updateAssets( remoteIds, isFavorite: isFavorite, visibility: visibility, createdAt: parsedDateTime, ); - await _exifRepository.update( + await _exifRepository.updateExif( remoteIds, location: location, dateTimeOriginal: parsedDateTime, @@ -165,7 +165,7 @@ class AssetService { } await _apiRepository.delete(remoteIds, true); - await _remoteRepository.delete(remoteIds); + await _remoteRepository.deleteAssets(remoteIds); } Future applyEdits(String remoteId, List edits) async { @@ -189,7 +189,7 @@ class AssetService { if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { await _trashedLocalRepository.applyTrashedAssets(deletedIds); } else { - await _localRepository.delete(deletedIds); + await _localRepository.deleteAssets(deletedIds); } return deletedIds.length; } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 8161df5c51..817cc33380 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -19,8 +19,6 @@ import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; @@ -76,31 +74,32 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { : _backgroundHostApi = BackgroundWorkerBgHostApi() { final ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]); _ref = ref; + final db = ref.read(driftProvider); _localSyncService = LocalSyncService( - localAlbumRepository: ref.read(localAlbumRepository), - localAssetRepository: ref.read(localAssetRepository), + localAlbumRepository: db.localAlbumRepository, + localAssetRepository: db.localAssetRepository, nativeSyncApi: ref.read(nativeSyncApiProvider), - trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository), + trashedLocalAssetRepository: db.trashedLocalAssetRepository, assetMediaRepository: ref.read(assetMediaRepositoryProvider), permissionRepository: ref.read(permissionRepositoryProvider), cancellation: _cancellationToken, ); _remoteSyncService = SyncStreamService( syncApiRepository: ref.read(syncApiRepositoryProvider), - syncStreamRepository: ref.read(syncStreamRepositoryProvider), - localAssetRepository: ref.read(localAssetRepository), - trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository), + syncStreamRepository: db.syncStreamRepository, + localAssetRepository: db.localAssetRepository, + trashedLocalAssetRepository: db.trashedLocalAssetRepository, assetMediaRepository: ref.read(assetMediaRepositoryProvider), permissionRepository: ref.read(permissionRepositoryProvider), - syncMigrationRepository: ref.read(syncMigrationRepositoryProvider), + syncMigrationRepository: db.syncMigrationRepository, api: ref.read(apiServiceProvider), cancellation: _cancellationToken, ); _hashService = HashService( - localAlbumRepository: ref.read(localAlbumRepository), - localAssetRepository: ref.read(localAssetRepository), + localAlbumRepository: db.localAlbumRepository, + localAssetRepository: db.localAssetRepository, nativeSyncApi: ref.read(nativeSyncApiProvider), - trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository), + trashedLocalAssetRepository: db.trashedLocalAssetRepository, cancellation: _cancellationToken, ); BackgroundWorkerFlutterApi.setUp(this); diff --git a/mobile/lib/domain/services/device_permission.service.dart b/mobile/lib/domain/services/device_permission.service.dart new file mode 100644 index 0000000000..d6ce128b8a --- /dev/null +++ b/mobile/lib/domain/services/device_permission.service.dart @@ -0,0 +1,47 @@ +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; + +typedef _Handler = Future Function(DevicePermission); + +class DevicePermissionService { + const DevicePermissionService(this._permissionRepository); + + final DevicePermissionRepository _permissionRepository; + + Future galleryStatus() => _gallery(_permissionRepository.getStatus); + + Future requestGallery() => _gallery(_permissionRepository.request); + + Future _gallery(_Handler handler) async { + if (CurrentPlatform.isIOS) { + return handler(.photos); + } + + final sdkVersion = await _permissionRepository.getAndroidSdkVersion(); + const maxExternalStorageSdk = 32; // READ/WRITE_EXTERNAL_STORAGE - Android 12.1 + final status = sdkVersion <= maxExternalStorageSdk ? await handler(.storage) : await _photosAndVideos(handler); + + const minMediaLocationSdk = 29; // ACCESS_MEDIA_LOCATION - Android 10 + if (status.hasAccess && sdkVersion >= minMediaLocationSdk) { + final mediaLocation = await handler(.mediaLocation); + return mediaLocation.hasAccess ? status : mediaLocation; + } + + return status; + } + + Future _photosAndVideos(_Handler handler) async { + final photos = await handler(.photos); + if (!photos.hasAccess) { + return photos; + } + + final videos = await handler(.videos); + if (!videos.hasAccess) { + return videos; + } + + return photos == .granted && videos == .granted ? .granted : .limited; + } +} diff --git a/mobile/lib/domain/services/feature_message.service.dart b/mobile/lib/domain/services/feature_message.service.dart index ff0fdda8f3..5eb58f0823 100644 --- a/mobile/lib/domain/services/feature_message.service.dart +++ b/mobile/lib/domain/services/feature_message.service.dart @@ -9,7 +9,7 @@ class FeatureMessageService { bool shouldShow() { final seen = _settingsRepository.appConfig.featureMessage.seenRelease; - return featureMessageHighlights.isNotEmpty && featureMessageRelease > seen; + return visibleFeatureMessageHighlights.isNotEmpty && featureMessageRelease > seen; } Future markSeen() => _settingsRepository.write(SettingsKey.featureMessageSeenRelease, featureMessageRelease); diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index b0dbf8fbea..64595aff2b 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -15,9 +15,9 @@ const String _kHashCancelledCode = "HASH_CANCELLED"; class HashService { final int _batchSize; - final DriftLocalAlbumRepository _localAlbumRepository; - final DriftLocalAssetRepository _localAssetRepository; - final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; + final LocalAlbumRepository _localAlbumRepository; + final LocalAssetRepository _localAssetRepository; + final TrashedLocalAssetRepository _trashedLocalAssetRepository; final NativeSyncApi _nativeSyncApi; final Completer? _cancellation; final _log = Logger('HashService'); @@ -66,11 +66,12 @@ class HashService { await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true); } } - } on PlatformException catch (e) { + } on PlatformException catch (e, s) { if (e.code == _kHashCancelledCode) { _log.warning("Hashing cancelled by platform"); return; } + _log.severe("Native hashing failed: ${e.code}", e, s); } catch (e, s) { _log.severe("Error during hashing", e, s); } diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart index e3d888f063..8661a8d10a 100644 --- a/mobile/lib/domain/services/local_album.service.dart +++ b/mobile/lib/domain/services/local_album.service.dart @@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; class LocalAlbumService { - final DriftLocalAlbumRepository _repository; + final LocalAlbumRepository _repository; const LocalAlbumService(this._repository); diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index b4ebc66f89..0793d191b0 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -21,13 +21,13 @@ import 'package:logging/logging.dart'; const String _kSyncCancelledCode = "SYNC_CANCELLED"; class LocalSyncService { - final DriftLocalAlbumRepository _localAlbumRepository; + final LocalAlbumRepository _localAlbumRepository; // ignore: unused_field - final DriftLocalAssetRepository _localAssetRepository; + final LocalAssetRepository _localAssetRepository; final NativeSyncApi _nativeSyncApi; - final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; + final TrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; - final IPermissionRepository _permissionRepository; + final DevicePermissionRepository _permissionRepository; final Completer? _cancellation; final Logger _log = Logger("DeviceSyncService"); @@ -186,7 +186,7 @@ class LocalSyncService { _log.fine("Removing device album ${a.name}"); try { // Asset deletion is handled in the repository - await _localAlbumRepository.delete(a.id); + await _localAlbumRepository.deleteAlbum(a.id); } catch (e, s) { _log.warning("Error while removing device album", e, s); } diff --git a/mobile/lib/domain/services/map.service.dart b/mobile/lib/domain/services/map.service.dart index 6c484d1a14..5e0a3bd857 100644 --- a/mobile/lib/domain/services/map.service.dart +++ b/mobile/lib/domain/services/map.service.dart @@ -8,7 +8,7 @@ typedef MapMarkerSource = Future> Function(LatLngBounds? bounds); typedef MapQuery = ({MapMarkerSource markerSource}); class MapFactory { - final DriftMapRepository _mapRepository; + final MapRepository _mapRepository; const MapFactory({required this._mapRepository}); diff --git a/mobile/lib/domain/services/memory.service.dart b/mobile/lib/domain/services/memory.service.dart index ead613370f..ccb865fb64 100644 --- a/mobile/lib/domain/services/memory.service.dart +++ b/mobile/lib/domain/services/memory.service.dart @@ -5,7 +5,7 @@ import 'package:logging/logging.dart'; class DriftMemoryService { final log = Logger("DriftMemoryService"); - final DriftMemoryRepository _repository; + final MemoryRepository _repository; DriftMemoryService(this._repository); diff --git a/mobile/lib/domain/services/partner.service.dart b/mobile/lib/domain/services/partner.service.dart index 63985823aa..3fd0d32683 100644 --- a/mobile/lib/domain/services/partner.service.dart +++ b/mobile/lib/domain/services/partner.service.dart @@ -27,7 +27,7 @@ class PartnerService { Future update({required String sharedById, required String sharedWithId, required bool inTimeline}) async { await _partnerApiRepository.update(sharedById, inTimeline: inTimeline); - await _partnerRepository.update(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline); + await _partnerRepository.updatePartner(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline); } Future create({required String sharedById, required String sharedWithId, bool inTimeline = false}) async { @@ -37,6 +37,6 @@ class PartnerService { Future delete({required String sharedById, required String sharedWithId}) async { await _partnerApiRepository.delete(sharedWithId); - await _partnerRepository.delete(sharedById: sharedById, sharedWithId: sharedWithId); + await _partnerRepository.deletePartner(sharedById: sharedById, sharedWithId: sharedWithId); } } diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index 73972dbdc0..e0323246b0 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -5,21 +5,21 @@ import 'package:immich_mobile/infrastructure/repositories/people.repository.dart import 'package:immich_mobile/repositories/person_api.repository.dart'; class DriftPeopleService { - final DriftPeopleRepository _repository; + final PeopleRepository _repository; final PersonApiRepository _personApiRepository; const DriftPeopleService(this._repository, this._personApiRepository); - Future get(String personId) { + Future get(String personId) { return _repository.get(personId); } - Future> getAssetPeople(String assetId) { + Future> getAssetPeople(String assetId) { return _repository.getAssetPeople(assetId); } - Future> getAllPeople({int minFaces = 3}) { - return _repository.getAllPeople(minFaces: minFaces); + Stream> watch({int minFaces = 3}) { + return _repository.watch(minFaces: minFaces); } Future updateName(String personId, String name) async { @@ -27,7 +27,7 @@ class DriftPeopleService { return _repository.updateName(personId, name); } - Future updateBrithday(String personId, DateTime birthday) async { + Future updateBirthday(String personId, DateTime birthday) async { await _personApiRepository.update(personId, birthday: birthday); return _repository.updateBirthday(personId, birthday); } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index e59d75b01a..8e6a6c8aae 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -25,7 +25,7 @@ class AlbumAssetCandidates { class RemoteAlbumService { static final _logger = Logger('RemoteAlbumService'); - final DriftRemoteAlbumRepository _repository; + final RemoteAlbumRepository _repository; final DriftAlbumApiRepository _albumApiRepository; final ForegroundUploadService _uploadService; @@ -152,7 +152,7 @@ class RemoteAlbumService { ); // Update the local database - await _repository.update(updatedAlbum); + await _repository.updateAlbum(updatedAlbum); return updatedAlbum; } diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart index 758622a43b..ea830dc322 100644 --- a/mobile/lib/domain/services/store.service.dart +++ b/mobile/lib/domain/services/store.service.dart @@ -6,13 +6,13 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart' /// Provides access to a persistent key-value store with an in-memory cache. /// Listens for repository changes to keep the cache updated. class StoreService { - final DriftStoreRepository _storeRepository; + final StoreRepository _storeRepository; /// In-memory cache. Keys are [StoreKey.id] final Map _cache = {}; StreamSubscription>? _storeUpdateSubscription; - StoreService._({required DriftStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository; + StoreService._({required StoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository; // TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider static StoreService? _instance; @@ -24,12 +24,12 @@ class StoreService { } // TODO: Replace the implementation with the one from create after removing the typedef - static Future init({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async { + static Future init({required StoreRepository storeRepository, bool listenUpdates = true}) async { _instance ??= await create(storeRepository: storeRepository, listenUpdates: listenUpdates); return _instance!; } - static Future create({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async { + static Future create({required StoreRepository storeRepository, bool listenUpdates = true}) async { final instance = StoreService._(isarStoreRepository: storeRepository); await instance.populateCache(); if (listenUpdates) { @@ -90,7 +90,7 @@ class StoreService { /// Removes the value for [key] Future delete(StoreKey key) async { - await _storeRepository.delete(key); + await _storeRepository.deleteValue(key); _cache.remove(key.id); } diff --git a/mobile/lib/domain/services/sync_linked_album.service.dart b/mobile/lib/domain/services/sync_linked_album.service.dart index 091ca222f0..80a1fbaf9e 100644 --- a/mobile/lib/domain/services/sync_linked_album.service.dart +++ b/mobile/lib/domain/services/sync_linked_album.service.dart @@ -6,24 +6,25 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:logging/logging.dart'; -final syncLinkedAlbumServiceProvider = Provider( - (ref) => SyncLinkedAlbumService( - ref.watch(localAlbumRepository), - ref.watch(remoteAlbumRepository), +final syncLinkedAlbumServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return SyncLinkedAlbumService( + db.localAlbumRepository, + db.remoteAlbumRepository, ref.watch(driftAlbumApiRepositoryProvider), ref.watch(storeServiceProvider), - ), -); + ); +}); class SyncLinkedAlbumService { - final DriftLocalAlbumRepository _localAlbumRepository; - final DriftRemoteAlbumRepository _remoteAlbumRepository; + final LocalAlbumRepository _localAlbumRepository; + final RemoteAlbumRepository _remoteAlbumRepository; final DriftAlbumApiRepository _albumApiRepository; final StoreService _storeService; diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 9b4d4bb275..2f6ad3f045 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -33,10 +34,10 @@ class SyncStreamService { final SyncApiRepository _syncApiRepository; final SyncStreamRepository _syncStreamRepository; - final DriftLocalAssetRepository _localAssetRepository; - final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; + final LocalAssetRepository _localAssetRepository; + final TrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; - final IPermissionRepository _permissionRepository; + final DevicePermissionRepository _permissionRepository; final SyncMigrationRepository _syncMigrationRepository; final ApiService _api; final Completer? _cancellation; @@ -122,7 +123,7 @@ class SyncStreamService { } if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) && - semVer >= const SemVer(major: 2, minor: 5, patch: 0)) { + semVer.supports(.assetPayloadChange20260128)) { _logger.info("Running pre-sync task: v20260128_ResetAssetV1"); await _syncApiRepository.deleteSyncAck([ SyncEntityType.assetV1, @@ -139,7 +140,7 @@ class SyncStreamService { } if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) && - semVer > const SemVer(major: 2, minor: 7, patch: 5)) { + semVer.supports(.assetPayloadChange20260597)) { _logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2"); await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]); migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name); diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 2ac8d4f55c..2d35ceb6c8 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -38,7 +38,7 @@ enum TimelineOrigin { } class TimelineFactory { - final DriftTimelineRepository _timelineRepository; + final TimelineRepository _timelineRepository; final SettingsRepository _settingsRepository; const TimelineFactory({required this._timelineRepository, required this._settingsRepository}); diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 82f397d9b6..f1e0328165 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -28,6 +28,7 @@ class BackgroundSyncManager { final SyncErrorCallback? onCloudIdSyncError; Cancelable? _syncTask; + bool _syncQueued = false; Cancelable? _syncWebsocketTask; Cancelable? _cloudIdSyncTask; Cancelable? _deviceAlbumSyncTask; @@ -49,15 +50,39 @@ class BackgroundSyncManager { this.onCloudIdSyncError, }); + // The tasks the app-resume path re-runs. One in-flight when the app was suspended + // stays referenced but frozen, so on resume the dedupe guards would hand back the + // stale task instead of syncing (#28082). Websocket and cloud-id are excluded - the + // resume path never restarts them. [_allTasks] builds on this so the lists can't drift. + List get _resumeSyncTasks => [_syncTask, _deviceAlbumSyncTask, _hashTask, _linkedAlbumSyncTask]; + + List get _allTasks => [_syncWebsocketTask, _cloudIdSyncTask, ..._resumeSyncTasks]; + Future cancel() async { - final tasks = [ - _syncTask, - _syncWebsocketTask, - _cloudIdSyncTask, - _linkedAlbumSyncTask, - _deviceAlbumSyncTask, - _hashTask, - ]; + _syncQueued = false; + final tasks = _allTasks; + _syncTask = null; + _syncWebsocketTask = null; + _cloudIdSyncTask = null; + _linkedAlbumSyncTask = null; + _deviceAlbumSyncTask = null; + _hashTask = null; + await _cancelAll(tasks); + } + + Future cancelResumeSyncs() async { + _syncQueued = false; + final tasks = _resumeSyncTasks; + _syncTask = null; + _deviceAlbumSyncTask = null; + _hashTask = null; + _linkedAlbumSyncTask = null; + await _cancelAll(tasks); + } + + // Cancels every task in [tasks] and waits for them to unwind. Callers null out + // their own fields first, so the sync guards see a clean slate immediately. + Future _cancelAll(List tasks) async { final futures = [ for (final task in tasks) if (task != null) task.future, @@ -65,13 +90,6 @@ class BackgroundSyncManager { for (final task in tasks) { task?.cancel(); } - _syncTask = null; - _syncWebsocketTask = null; - _cloudIdSyncTask = null; - _linkedAlbumSyncTask = null; - _deviceAlbumSyncTask = null; - _hashTask = null; - try { await Future.wait(futures); } on CanceledError { @@ -82,14 +100,14 @@ class BackgroundSyncManager { // No need to cancel the task, as it can also be run when the user logs out Future syncLocal({bool full = false}) { if (_deviceAlbumSyncTask != null) { - return _deviceAlbumSyncTask!.future; + return _deviceAlbumSyncTask!.future.catchError((_) {}, test: (error) => error is CanceledError); } onLocalSyncStart?.call(); // We use a ternary operator to avoid [_deviceAlbumSyncTask] from being // captured by the closure passed to [runInIsolateGentle]. - _deviceAlbumSyncTask = full + final task = _deviceAlbumSyncTask = full ? runInIsolateGentle( computation: (ref) => ref.read(localSyncServiceProvider).sync(full: true), debugLabel: 'local-sync-full-true', @@ -99,64 +117,82 @@ class BackgroundSyncManager { debugLabel: 'local-sync-full-false', ); - return _deviceAlbumSyncTask! + return task .whenComplete(() { - _deviceAlbumSyncTask = null; + if (identical(_deviceAlbumSyncTask, task)) { + _deviceAlbumSyncTask = null; + } onLocalSyncComplete?.call(); }) .catchError((error) { - onLocalSyncError?.call(error.toString()); - _deviceAlbumSyncTask = null; + if (error is! CanceledError) { + onLocalSyncError?.call(error.toString()); + } }); } Future hashAssets() { if (_hashTask != null) { - return _hashTask!.future; + return _hashTask!.future.catchError((_) {}, test: (error) => error is CanceledError); } onHashingStart?.call(); - _hashTask = runInIsolateGentle( + final task = _hashTask = runInIsolateGentle( computation: (ref) => ref.read(hashServiceProvider).hashAssets(), debugLabel: 'hash-assets', ); - return _hashTask! + return task .whenComplete(() { onHashingComplete?.call(); - _hashTask = null; + if (identical(_hashTask, task)) { + _hashTask = null; + } }) .catchError((error) { - onHashingError?.call(error.toString()); - _hashTask = null; + if (error is! CanceledError) { + onHashingError?.call(error.toString()); + } }); } - Future syncRemote() { + Future syncRemote({bool enqueue = false}) { if (_syncTask != null) { + _syncQueued |= enqueue; return _syncTask!.future.then((result) => result ?? false).catchError((_) => false); } onRemoteSyncStart?.call(); - _syncTask = runInIsolateGentle( + final task = _syncTask = runInIsolateGentle( computation: (ref) => ref.read(syncStreamServiceProvider).sync(), debugLabel: 'remote-sync', ); - return _syncTask! + return task .then((result) { final success = result ?? false; onRemoteSyncComplete?.call(success); + _syncQueued &= success; return success; }) .catchError((error) { - onRemoteSyncError?.call(error.toString()); - _syncTask = null; + if (error is! CanceledError) { + onRemoteSyncError?.call(error.toString()); + } + _syncQueued = false; return false; }) + // A task clears only its own slot: one that was cancelled and superseded by a + // fresh task (see cancelResumeSyncs) must not null the new task's slot. .whenComplete(() { - _syncTask = null; + if (identical(_syncTask, task)) { + _syncTask = null; + if (_syncQueued) { + _syncQueued = false; + unawaited(syncRemote()); + } + } }); } @@ -202,13 +238,21 @@ class BackgroundSyncManager { Future syncLinkedAlbum() { if (_linkedAlbumSyncTask != null) { - return _linkedAlbumSyncTask!.future; + return _linkedAlbumSyncTask!.future.catchError((_) {}, test: (error) => error is CanceledError); } - _linkedAlbumSyncTask = runInIsolateGentle(computation: syncLinkedAlbumsIsolated, debugLabel: 'linked-album-sync'); - return _linkedAlbumSyncTask!.whenComplete(() { - _linkedAlbumSyncTask = null; - }); + final task = _linkedAlbumSyncTask = runInIsolateGentle( + computation: syncLinkedAlbumsIsolated, + debugLabel: 'linked-album-sync', + ); + return task + .whenComplete(() { + if (identical(_linkedAlbumSyncTask, task)) { + _linkedAlbumSyncTask = null; + } + }) + // a cancelled resume sync is not a failure; absorb it so the websocket callers don't get an uncaught error + .catchError((_) {}, test: (error) => error is CanceledError); } Future syncCloudIds() { diff --git a/mobile/lib/domain/utils/migrate_cloud_ids.dart b/mobile/lib/domain/utils/migrate_cloud_ids.dart index efef6e8327..ecdf5fc9a0 100644 --- a/mobile/lib/domain/utils/migrate_cloud_ids.dart +++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -31,12 +32,12 @@ Future syncCloudIds(ProviderContainer ref) async { await _populateCloudIds(db); final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo(); - final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4); + final canUpdateMetadata = serverInfo.serverVersion.supports(.cloudIdMetadata); if (!canUpdateMetadata) { logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.'); return; } - final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5); + final canBulkUpdateMetadata = serverInfo.serverVersion.supports(.bulkCloudIdMetadata); // Wait for remote sync to complete, so we have up-to-date asset metadata entries try { @@ -171,7 +172,7 @@ Future _populateCloudIds(Drift drift) async { ); } } - await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping); + await LocalAlbumRepository(drift).updateCloudMapping(cloudMapping); } typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset}); diff --git a/mobile/lib/extensions/translate_extensions.dart b/mobile/lib/extensions/translate_extensions.dart deleted file mode 100644 index b01203a90c..0000000000 --- a/mobile/lib/extensions/translate_extensions.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:intl/message_format.dart'; - -extension StringTranslateExtension on String { - String t({BuildContext? context, Map? args}) { - return _translateHelper(context, this, args); - } -} - -extension TextTranslateExtension on Text { - Text t({BuildContext? context, Map? args}) { - return Text( - _translateHelper(context, data ?? '', args), - key: key, - style: style, - strutStyle: strutStyle, - textAlign: textAlign, - textDirection: textDirection, - locale: locale, - softWrap: softWrap, - overflow: overflow, - textScaler: textScaler, - maxLines: maxLines, - semanticsLabel: semanticsLabel, - textWidthBasis: textWidthBasis, - textHeightBehavior: textHeightBehavior, - ); - } -} - -String _translateHelper(BuildContext? context, String key, [Map? args]) { - if (key.isEmpty) { - return ''; - } - try { - final translatedMessage = key.tr(context: context); - return args != null - ? MessageFormat(translatedMessage, locale: Intl.defaultLocale ?? 'en').format(args) - : translatedMessage; - } catch (e) { - dPrint(() => 'Translation failed for key "$key". Error: $e'); - return key; - } -} diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index 8b7cdc7621..6b2be218dc 100644 --- a/mobile/lib/infrastructure/loaders/image_request.dart +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:ffi'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:ffi/ffi.dart'; @@ -36,7 +37,11 @@ abstract class ImageRequest { void _onCancelled(); - Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async { + Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage( + int address, + int length, { + ui.Size? decodeSize, + }) async { final pointer = Pointer.fromAddress(address); if (_isCancelled) { malloc.free(pointer); @@ -62,7 +67,8 @@ abstract class ImageRequest { return null; } - final codec = await descriptor.instantiateCodec(); + final target = _targetSize(descriptor.width, descriptor.height, decodeSize); + final codec = await descriptor.instantiateCodec(targetWidth: target?.$1, targetHeight: target?.$2); if (_isCancelled) { descriptor.dispose(); codec.dispose(); @@ -72,8 +78,8 @@ abstract class ImageRequest { return (codec, descriptor); } - Future _fromEncodedPlatformImage(int address, int length) async { - final result = await _codecFromEncodedPlatformImage(address, length); + Future _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async { + final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize); if (result == null) { return null; } @@ -96,6 +102,19 @@ abstract class ImageRequest { return frame; } + (int, int)? _targetSize(int width, int height, ui.Size? decodeSize) { + if (width <= 0 || height <= 0 || decodeSize == null || decodeSize.width <= 0 || decodeSize.height <= 0) { + return null; + } + + final scale = math.max(decodeSize.width / width, decodeSize.height / height); + if (scale >= 1) { + return null; + } + + return ((width * scale).ceil(), (height * scale).ceil()); + } + Future _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async { final pointer = Pointer.fromAddress(address); if (_isCancelled) { diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index d6a25753fb..7b504697ac 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -3,7 +3,10 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { final String uri; - RemoteImageRequest({required this.uri}); + /// Physical size to decode, or null for the source size. + final ui.Size? decodeSize; + + RemoteImageRequest({required this.uri, this.decodeSize}); @override Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { @@ -11,10 +14,20 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false); + final info = await remoteImageApi.requestImage( + uri, + requestId: requestId, + preferEncoded: false, + width: decodeSize?.width.ceil(), + height: decodeSize?.height.ceil(), + ); // Android falls back to encoded data if native decoding fails, so check for both shapes of the response. final frame = switch (info) { - {'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(pointer, length), + {'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage( + pointer, + length, + decodeSize: decodeSize, + ), { 'pointer': final int pointer, 'width': final int width, @@ -33,7 +46,13 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true); + final info = await remoteImageApi.requestImage( + uri, + requestId: requestId, + preferEncoded: true, + width: null, + height: null, + ); if (info == null) { return null; } diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart index eb3cba95d0..c4c07118d6 100644 --- a/mobile/lib/infrastructure/repositories/backup.repository.dart +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -1,21 +1,18 @@ import 'dart:async'; import 'package:drift/drift.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -final backupRepositoryProvider = Provider( - (ref) => DriftBackupRepository(ref.watch(driftProvider)), -); +@DriftAccessor() +class BackupRepository extends DatabaseAccessor with $BackupRepositoryMixin { + BackupRepository(super.attachedDatabase); -class DriftBackupRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftBackupRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; JoinedSelectStatement<$LocalAlbumAssetEntityTable, LocalAlbumAssetEntityData> _getExcludedSubquery() { return _db.localAlbumAssetEntity.selectOnly() diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 69e45685de..195f1455d2 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -32,8 +32,27 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/people.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -69,6 +88,28 @@ import 'package:sqlite_async/sqlite_async.dart'; AssetOcrEntity, ], include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, + daos: [ + AuthUserRepository, + BackupRepository, + LocalAlbumRepository, + LocalAssetRepository, + MapRepository, + MemoryRepository, + OcrRepository, + PartnerRepository, + PeopleRepository, + RemoteAlbumRepository, + RemoteAssetRepository, + RemoteExifRepository, + StackRepository, + StoreRepository, + SyncMigrationRepository, + SyncStreamRepository, + TimelineRepository, + TrashedLocalAssetRepository, + UserMetadataRepository, + UserRepository, + ], ) class Drift extends $Drift { final SqliteConnectionPool? _updatePool; @@ -344,13 +385,6 @@ class Drift extends $Drift { ); } -class DriftDatabaseRepository { - final Drift _db; - const DriftDatabaseRepository(this._db); - - Future transaction(Future Function() callback) => _db.transaction(callback); -} - // ignore: invalid_use_of_internal_member final class _DriftPoolStreamQueries extends StreamQueryStore { _DriftPoolStreamQueries(this._pool); diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index a9911fe044..780b4f5ef9 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -10,13 +10,15 @@ import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.d import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.drift.dart'; enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset } -class DriftLocalAlbumRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class LocalAlbumRepository extends DatabaseAccessor with $LocalAlbumRepositoryMixin { + LocalAlbumRepository(super.attachedDatabase); - const DriftLocalAlbumRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; Future> getAll({Set sortBy = const {}}) { final assetCount = _db.localAlbumAssetEntity.assetId.count(); @@ -57,7 +59,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future delete(String albumId) => transaction(() async { + Future deleteAlbum(String albumId) => transaction(() async { // Remove all assets that are only in this particular album // We cannot remove all assets in the album because they might be in other albums in iOS // That is not the case on Android since asset <-> album has one:one mapping diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 8396d6d2a6..e035bddea4 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.drift.dart'; class RemovalCandidatesResult { final List assets; @@ -18,10 +19,11 @@ class RemovalCandidatesResult { const RemovalCandidatesResult({required this.assets, required this.totalBytes}); } -class DriftLocalAssetRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class LocalAssetRepository extends DatabaseAccessor with $LocalAssetRepositoryMixin { + LocalAssetRepository(super.attachedDatabase); - const DriftLocalAssetRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; SingleOrNullSelectable _assetSelectable(String id) { final query = @@ -72,7 +74,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { }); } - Future delete(List ids) { + Future deleteAssets(List ids) { if (ids.isEmpty) { return Future.value(); } diff --git a/mobile/lib/infrastructure/repositories/map.repository.dart b/mobile/lib/infrastructure/repositories/map.repository.dart index 267ea08d20..bd24985fbb 100644 --- a/mobile/lib/infrastructure/repositories/map.repository.dart +++ b/mobile/lib/infrastructure/repositories/map.repository.dart @@ -5,13 +5,15 @@ import 'package:immich_mobile/domain/services/map.service.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/map.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class DriftMapRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class MapRepository extends DatabaseAccessor with $MapRepositoryMixin { + MapRepository(super.attachedDatabase); - const DriftMapRepository(super._db) : _db = _db; + Drift get _db => attachedDatabase; MapQuery remote(List ownerIds, TimelineMapOptions options) => _mapQueryBuilder( assetFilter: (row) { diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index 37cccbf2d4..11bb99ba7b 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -4,10 +4,13 @@ import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.drift.dart'; -class DriftMemoryRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftMemoryRepository(this._db) : super(_db); +@DriftAccessor() +class MemoryRepository extends DatabaseAccessor with $MemoryRepositoryMixin { + MemoryRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> getAll(String ownerId) async { final now = DateTime.now(); diff --git a/mobile/lib/infrastructure/repositories/ocr.repository.dart b/mobile/lib/infrastructure/repositories/ocr.repository.dart index c3465df357..f374937b4a 100644 --- a/mobile/lib/infrastructure/repositories/ocr.repository.dart +++ b/mobile/lib/infrastructure/repositories/ocr.repository.dart @@ -2,10 +2,13 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/ocr.model.dart'; import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/ocr.repository.drift.dart'; -class OcrRepository extends DriftDatabaseRepository { - final Drift _db; - const OcrRepository(this._db) : super(_db); +@DriftAccessor() +class OcrRepository extends DatabaseAccessor with $OcrRepositoryMixin { + OcrRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> get(String assetId) async { final query = _db.select(_db.assetOcrEntity) diff --git a/mobile/lib/infrastructure/repositories/partner.repository.dart b/mobile/lib/infrastructure/repositories/partner.repository.dart index ee18c84b4e..fd38f12686 100644 --- a/mobile/lib/infrastructure/repositories/partner.repository.dart +++ b/mobile/lib/infrastructure/repositories/partner.repository.dart @@ -4,10 +4,13 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; import 'package:immich_mobile/infrastructure/mapper.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/partner.repository.drift.dart'; -class PartnerRepository { - final Drift _db; - const PartnerRepository(this._db); +@DriftAccessor() +class PartnerRepository extends DatabaseAccessor with $PartnerRepositoryMixin { + PartnerRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future get({required String sharedById, required String sharedWithId}) => (_db.select(_db.partnerEntity).join([ @@ -46,11 +49,11 @@ class PartnerRepository { ), ); - Future update({required String sharedById, required String sharedWithId, required bool inTimeline}) => + Future updatePartner({required String sharedById, required String sharedWithId, required bool inTimeline}) => (_db.partnerEntity.update()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId))) .write(PartnerEntityCompanion(inTimeline: Value(inTimeline))); - Future delete({required String sharedById, required String sharedWithId}) => + Future deletePartner({required String sharedById, required String sharedWithId}) => (_db.partnerEntity.delete()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId))) .go(); diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index 0dd73cc81d..52ba362332 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -3,19 +3,22 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/people.repository.drift.dart'; -class DriftPeopleRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftPeopleRepository(this._db) : super(_db); +@DriftAccessor() +class PeopleRepository extends DatabaseAccessor with $PeopleRepositoryMixin { + PeopleRepository(super.attachedDatabase); - Future get(String personId) async { + Drift get _db => attachedDatabase; + + Future get(String personId) async { final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId)); final result = await query.getSingleOrNull(); return result?.toDto(); } - Future> getAssetPeople(String assetId) async { + Future> getAssetPeople(String assetId) async { // An asset can have multiple face records for the same person (e.g., metadata // imports alongside ML detections). Use a subquery instead of a join so each // person is returned once, regardless of how many of their faces are on the asset @@ -33,7 +36,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future> getAllPeople({int minFaces = 3}) async { + Stream> watch({int minFaces = 3}) { final people = _db.personEntity; final faces = _db.assetFaceEntity; final assets = _db.remoteAssetEntity; @@ -59,7 +62,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) { final person = row.readTable(people); return person.toDto(); - }).get(); + }).watch(); } Future updateName(String personId, String name) { @@ -76,18 +79,5 @@ class DriftPeopleRepository extends DriftDatabaseRepository { } extension on PersonEntityData { - DriftPerson toDto() { - return DriftPerson( - id: id, - createdAt: createdAt, - updatedAt: updatedAt, - ownerId: ownerId, - name: name, - faceAssetId: faceAssetId, - isFavorite: isFavorite, - isHidden: isHidden, - color: color, - birthDate: birthDate, - ); - } + Person toDto() => Person(id: id, updatedAt: updatedAt, name: name, birthDate: birthDate); } diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index b9cd41c960..2f7106c270 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -12,12 +12,15 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.drift.dart'; enum SortRemoteAlbumsBy { id, updatedAt } -class DriftRemoteAlbumRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftRemoteAlbumRepository(this._db) : super(_db); +@DriftAccessor() +class RemoteAlbumRepository extends DatabaseAccessor with $RemoteAlbumRepositoryMixin { + RemoteAlbumRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> getAll({Set sortBy = const {SortRemoteAlbumsBy.updatedAt}}) { // Count non-trashed assets via the joined asset table. Filtering trashed assets in the @@ -191,7 +194,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { }); } - Future update(RemoteAlbum album) async { + Future updateAlbum(RemoteAlbum album) async { await _db.remoteAlbumEntity.update().replace( RemoteAlbumEntityCompanion( id: Value(album.id), diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index d18284b636..e6fd0e9c89 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -10,13 +10,15 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.drift.dart'; import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class RemoteAssetRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class RemoteAssetRepository extends DatabaseAccessor with $RemoteAssetRepositoryMixin { + RemoteAssetRepository(super.attachedDatabase); - const RemoteAssetRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; /// For testing purposes Future> getSome(String userId) { @@ -163,7 +165,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository { ); } - Future delete(List ids) { + Future deleteAssets(List ids) { return _db.batch((batch) { for (final id in ids) { batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id)); @@ -244,7 +246,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()!).get(); } - Future update( + Future updateAssets( List remoteIds, { Option isFavorite = const .none(), Option visibility = const .none(), diff --git a/mobile/lib/infrastructure/repositories/remote_exif.repository.dart b/mobile/lib/infrastructure/repositories/remote_exif.repository.dart index 568342b321..f3064104f2 100644 --- a/mobile/lib/infrastructure/repositories/remote_exif.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_exif.repository.dart @@ -1,14 +1,17 @@ +import 'package:drift/drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.drift.dart'; import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class RemoteExifRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class RemoteExifRepository extends DatabaseAccessor with $RemoteExifRepositoryMixin { + RemoteExifRepository(super.attachedDatabase); - const RemoteExifRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; - Future update( + Future updateExif( List ids, { Option dateTimeOriginal = const .none(), Option timeZone = const .none(), diff --git a/mobile/lib/infrastructure/repositories/stack.repository.dart b/mobile/lib/infrastructure/repositories/stack.repository.dart index 28f7496f97..eecf7b9147 100644 --- a/mobile/lib/infrastructure/repositories/stack.repository.dart +++ b/mobile/lib/infrastructure/repositories/stack.repository.dart @@ -2,10 +2,13 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/stack.repository.drift.dart'; -class DriftStackRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftStackRepository(this._db) : super(_db); +@DriftAccessor() +class StackRepository extends DatabaseAccessor with $StackRepositoryMixin { + StackRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> getAll(String userId) { final query = _db.stackEntity.select()..where((e) => e.ownerId.equals(userId)); diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index 9680aa0425..62cf38c209 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -3,13 +3,16 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; -class DriftStoreRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class StoreRepository extends DatabaseAccessor with $StoreRepositoryMixin { final validStoreKeys = StoreKey.values.map((e) => e.id).toSet(); - DriftStoreRepository(super.db) : _db = db; + StoreRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future deleteAll() async { await _db.storeEntity.deleteAll(); @@ -27,7 +30,7 @@ class DriftStoreRepository extends DriftDatabaseRepository { return query.asyncMap((entity) => _toUpdateEvent(entity)).watch(); } - Future delete(StoreKey key) async { + Future deleteValue(StoreKey key) async { await _db.storeEntity.deleteWhere((entity) => entity.id.equals(key.id)); return; } @@ -64,7 +67,7 @@ class DriftStoreRepository extends DriftDatabaseRepository { const (bool) => entity.intValue == 1, const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), const (UserDto) => - entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!), + entity.stringValue == null ? null : await AuthUserRepository(_db).get(entity.stringValue!), _ => null, } as T?; @@ -75,7 +78,7 @@ class DriftStoreRepository extends DriftDatabaseRepository { const (String) => (null, value as String), const (bool) => ((value as bool) ? 1 : 0, null), const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), - const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id), + const (UserDto) => (null, (await AuthUserRepository(_db).upsert(value as UserDto)).id), _ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"), }; return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue)); diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 303859da2f..4e27da1a3d 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -44,25 +45,16 @@ class SyncApiRepository { types: [ SyncRequestType.authUsersV1, SyncRequestType.usersV1, - serverVersion >= const SemVer(major: 3, minor: 0, patch: 0) - ? SyncRequestType.assetsV2 - : SyncRequestType.assetsV1, + serverVersion.supports(.syncV2) ? SyncRequestType.assetsV2 : SyncRequestType.assetsV1, SyncRequestType.assetExifsV1, - if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1, + if (serverVersion.supports(.assetEdits)) SyncRequestType.assetEditsV1, SyncRequestType.assetMetadataV1, SyncRequestType.partnersV1, - serverVersion >= const SemVer(major: 3, minor: 0, patch: 0) - ? SyncRequestType.partnerAssetsV2 - : SyncRequestType.partnerAssetsV1, + serverVersion.supports(.syncV2) ? SyncRequestType.partnerAssetsV2 : SyncRequestType.partnerAssetsV1, SyncRequestType.partnerAssetExifsV1, - if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0)) - SyncRequestType.albumsV1 - else - SyncRequestType.albumsV2, + serverVersion.supports(.syncV2) ? SyncRequestType.albumsV2 : SyncRequestType.albumsV1, SyncRequestType.albumUsersV1, - serverVersion >= const SemVer(major: 3, minor: 0, patch: 0) - ? SyncRequestType.albumAssetsV2 - : SyncRequestType.albumAssetsV1, + serverVersion.supports(.syncV2) ? SyncRequestType.albumAssetsV2 : SyncRequestType.albumAssetsV1, SyncRequestType.albumAssetExifsV1, SyncRequestType.albumToAssetsV1, SyncRequestType.memoriesV1, @@ -71,10 +63,8 @@ class SyncApiRepository { SyncRequestType.partnerStacksV1, SyncRequestType.userMetadataV1, SyncRequestType.peopleV1, - serverVersion >= const SemVer(major: 2, minor: 6, patch: 0) - ? SyncRequestType.assetFacesV2 - : SyncRequestType.assetFacesV1, - if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1, + serverVersion.supports(.assetFacesV2) ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1, + if (serverVersion.supports(.assetOcr)) SyncRequestType.assetOcrV1, ], ).toJson(), ); diff --git a/mobile/lib/infrastructure/repositories/sync_migration.repository.dart b/mobile/lib/infrastructure/repositories/sync_migration.repository.dart index 814c8780ad..eca451dd16 100644 --- a/mobile/lib/infrastructure/repositories/sync_migration.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_migration.repository.dart @@ -1,9 +1,12 @@ +import 'package:drift/drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.drift.dart'; -class SyncMigrationRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class SyncMigrationRepository extends DatabaseAccessor with $SyncMigrationRepositoryMixin { + SyncMigrationRepository(super.attachedDatabase); - const SyncMigrationRepository(super.db) : _db = db; + Drift get _db => attachedDatabase; Future v20260128CopyExifWidthHeightToAsset() async { await _db.customStatement(''' diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index c43de69c5d..d118b51c62 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -29,16 +29,19 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.drift.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart' as api show AlbumUserRole, AssetEditAction, AssetVisibility, UserMetadataKey; import 'package:openapi/api.dart' hide AlbumUserRole, AssetEditAction, AssetVisibility, UserMetadataKey; -class SyncStreamRepository extends DriftDatabaseRepository { - final Logger _logger = Logger('DriftSyncStreamRepository'); - final Drift _db; +@DriftAccessor() +class SyncStreamRepository extends DatabaseAccessor with $SyncStreamRepositoryMixin { + final Logger _logger = Logger('SyncStreamRepository'); - SyncStreamRepository(super.db) : _db = db; + SyncStreamRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future reset() async { _logger.fine("SyncResetV1 received. Resetting remote entities"); diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index a88d781fcc..5296316421 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.drift.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -33,10 +34,11 @@ class TimelineMapOptions { }); } -class DriftTimelineRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class TimelineRepository extends DatabaseAccessor with $TimelineRepositoryMixin { + TimelineRepository(super.attachedDatabase); - const DriftTimelineRepository(super._db) : _db = _db; + Drift get _db => attachedDatabase; Stream> watchTimelineUserIds(String userId) { final query = _db.partnerEntity.selectOnly() diff --git a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart index 0f50776e4e..ee762fd330 100644 --- a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart @@ -8,13 +8,15 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.d import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.drift.dart'; typedef TrashedAsset = ({String albumId, LocalAsset asset}); -class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class TrashedLocalAssetRepository extends DatabaseAccessor with $TrashedLocalAssetRepositoryMixin { + TrashedLocalAssetRepository(super.attachedDatabase); - const DriftTrashedLocalAssetRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; /// Matches remote_asset_entity rows owned by the current user. The asset is unique over (owner, checksum), /// so partners can have a duplicate checksum diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart index 6df7344991..157851ab58 100644 --- a/mobile/lib/infrastructure/repositories/user.repository.dart +++ b/mobile/lib/infrastructure/repositories/user.repository.dart @@ -4,18 +4,23 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/mapper.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; -class UserRepository { - final Drift _db; - const UserRepository(this._db); +@DriftAccessor() +class UserRepository extends DatabaseAccessor with $UserRepositoryMixin { + UserRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Stream> getAll() => _db.select(_db.userEntity).map(mapToUser).watch(); } -class DriftAuthUserRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftAuthUserRepository(super.db) : _db = db; +@DriftAccessor() +class AuthUserRepository extends DatabaseAccessor with $AuthUserRepositoryMixin { + AuthUserRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future get(String id) async { final user = await _db.managers.authUserEntity.filter((user) => user.id.equals(id)).getSingleOrNull(); diff --git a/mobile/lib/infrastructure/repositories/user_metadata.repository.dart b/mobile/lib/infrastructure/repositories/user_metadata.repository.dart index 173ec10b97..001e482f5c 100644 --- a/mobile/lib/infrastructure/repositories/user_metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/user_metadata.repository.dart @@ -3,9 +3,11 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -class DriftUserMetadataRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftUserMetadataRepository(this._db) : super(_db); +@DriftAccessor() +class UserMetadataRepository extends DatabaseAccessor { + UserMetadataRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> getUserMetadata(String userId) { final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(userId)); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 317733f1de..2f1cfdaf6a 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -16,7 +16,6 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; @@ -273,15 +272,12 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale), theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale), builder: (context, child) => ImmichTranslationProvider( - translations: ImmichTranslations( - submit: "submit".t(context: context), - password: "password".t(context: context), - ), + translations: ImmichTranslations(submit: context.t.submit, password: context.t.password), child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!), ), routerConfig: router.config( deepLinkBuilder: _deepLinkBuilder, - navigatorObservers: () => [AppNavigationObserver(ref: ref)], + navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()], ), ), ); diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart index d3f99aea64..9a589ef729 100644 --- a/mobile/lib/models/activities/activity.model.dart +++ b/mobile/lib/models/activities/activity.model.dart @@ -1,65 +1,21 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +part 'activity.model.freezed.dart'; + enum ActivityType { comment, like } -class Activity { - final String id; - final String? assetId; - final String? comment; - final DateTime createdAt; - final ActivityType type; - final UserDto user; - - const Activity({ - required this.id, - this.assetId, - this.comment, - required this.createdAt, - required this.type, - required this.user, - }); - - Activity copyWith({ - String? id, +@freezed +abstract class Activity with _$Activity { + const factory Activity({ + required String id, String? assetId, String? comment, - DateTime? createdAt, - ActivityType? type, - UserDto? user, - }) { - return Activity( - id: id ?? this.id, - assetId: assetId ?? this.assetId, - comment: comment ?? this.comment, - createdAt: createdAt ?? this.createdAt, - type: type ?? this.type, - user: user ?? this.user, - ); - } - - @override - String toString() { - return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; - } - - @override - bool operator ==(covariant Activity other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.assetId == assetId && - other.comment == comment && - other.createdAt == createdAt && - other.type == type && - other.user == user; - } - - @override - int get hashCode { - return id.hashCode ^ assetId.hashCode ^ comment.hashCode ^ createdAt.hashCode ^ type.hashCode ^ user.hashCode; - } + required DateTime createdAt, + required ActivityType type, + required UserDto user, + }) = _Activity; } class ActivityStats { diff --git a/mobile/lib/models/auth/auth_state.model.dart b/mobile/lib/models/auth/auth_state.model.dart index c8a8018929..0a3bf983b2 100644 --- a/mobile/lib/models/auth/auth_state.model.dart +++ b/mobile/lib/models/auth/auth_state.model.dart @@ -1,71 +1,17 @@ -class AuthState { - final String deviceId; - final String userId; - final String userEmail; - final bool isAuthenticated; - final String name; - final bool isAdmin; - final String profileImagePath; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const AuthState({ - required this.deviceId, - required this.userId, - required this.userEmail, - required this.isAuthenticated, - required this.name, - required this.isAdmin, - required this.profileImagePath, - }); +part 'auth_state.model.freezed.dart'; - AuthState copyWith({ - String? deviceId, - String? userId, - String? userEmail, - bool? isAuthenticated, - String? name, - bool? isAdmin, - String? profileImagePath, - }) { - return AuthState( - deviceId: deviceId ?? this.deviceId, - userId: userId ?? this.userId, - userEmail: userEmail ?? this.userEmail, - isAuthenticated: isAuthenticated ?? this.isAuthenticated, - name: name ?? this.name, - isAdmin: isAdmin ?? this.isAdmin, - profileImagePath: profileImagePath ?? this.profileImagePath, - ); - } - - @override - String toString() { - return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, profileImagePath: $profileImagePath)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is AuthState && - other.deviceId == deviceId && - other.userId == userId && - other.userEmail == userEmail && - other.isAuthenticated == isAuthenticated && - other.name == name && - other.isAdmin == isAdmin && - other.profileImagePath == profileImagePath; - } - - @override - int get hashCode { - return deviceId.hashCode ^ - userId.hashCode ^ - userEmail.hashCode ^ - isAuthenticated.hashCode ^ - name.hashCode ^ - isAdmin.hashCode ^ - profileImagePath.hashCode; - } +@freezed +abstract class AuthState with _$AuthState { + const factory AuthState({ + required String deviceId, + required String userId, + required String userEmail, + required bool isAuthenticated, + required String name, + required bool isAdmin, + required String profileImagePath, + }) = _AuthState; } diff --git a/mobile/lib/models/auth/auxilary_endpoint.model.dart b/mobile/lib/models/auth/auxilary_endpoint.model.dart index cd11918bed..8686ae9461 100644 --- a/mobile/lib/models/auth/auxilary_endpoint.model.dart +++ b/mobile/lib/models/auth/auxilary_endpoint.model.dart @@ -1,34 +1,14 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; -class AuxilaryEndpoint { - final String url; - final AuxCheckStatus status; +import 'package:freezed_annotation/freezed_annotation.dart'; - const AuxilaryEndpoint({required this.url, required this.status}); +part 'auxilary_endpoint.model.freezed.dart'; - AuxilaryEndpoint copyWith({String? url, AuxCheckStatus? status}) { - return AuxilaryEndpoint(url: url ?? this.url, status: status ?? this.status); - } +@Freezed(fromJson: false, toJson: false) +abstract class AuxilaryEndpoint with _$AuxilaryEndpoint { + const AuxilaryEndpoint._(); - @override - String toString() => 'AuxilaryEndpoint(url: $url, status: $status)'; - - @override - bool operator ==(covariant AuxilaryEndpoint other) { - if (identical(this, other)) { - return true; - } - - return other.url == url && other.status == status; - } - - @override - int get hashCode => url.hashCode ^ status.hashCode; - - Map toMap() { - return {'url': url, 'status': status.toMap()}; - } + const factory AuxilaryEndpoint({required String url, required AuxCheckStatus status}) = _AuxilaryEndpoint; factory AuxilaryEndpoint.fromMap(Map map) { return AuxilaryEndpoint( @@ -37,50 +17,23 @@ class AuxilaryEndpoint { ); } - String toJson() => json.encode(toMap()); - factory AuxilaryEndpoint.fromJson(String source) => AuxilaryEndpoint.fromMap(json.decode(source) as Map); } -class AuxCheckStatus { - final String name; - const AuxCheckStatus({required this.name}); - const AuxCheckStatus._(this.name); +// TODO(agg23): Should be an enum +@freezed +abstract class AuxCheckStatus with _$AuxCheckStatus { + const AuxCheckStatus._(); - static const loading = AuxCheckStatus._('loading'); - static const valid = AuxCheckStatus._('valid'); - static const error = AuxCheckStatus._('error'); - static const unknown = AuxCheckStatus._('unknown'); + const factory AuxCheckStatus({required String name}) = _AuxCheckStatus; - @override - bool operator ==(covariant AuxCheckStatus other) { - if (identical(this, other)) { - return true; - } - - return other.name == name; - } - - @override - int get hashCode => name.hashCode; - - AuxCheckStatus copyWith({String? name}) { - return AuxCheckStatus(name: name ?? this.name); - } - - Map toMap() { - return {'name': name}; - } + static const loading = AuxCheckStatus(name: 'loading'); + static const valid = AuxCheckStatus(name: 'valid'); + static const error = AuxCheckStatus(name: 'error'); + static const unknown = AuxCheckStatus(name: 'unknown'); factory AuxCheckStatus.fromMap(Map map) { return AuxCheckStatus(name: map['name'] as String); } - - String toJson() => json.encode(toMap()); - - factory AuxCheckStatus.fromJson(String source) => AuxCheckStatus.fromMap(json.decode(source) as Map); - - @override - String toString() => 'AuxCheckStatus(name: $name)'; } diff --git a/mobile/lib/models/auth/biometric_status.model.dart b/mobile/lib/models/auth/biometric_status.model.dart index c5c417d06d..691fee3468 100644 --- a/mobile/lib/models/auth/biometric_status.model.dart +++ b/mobile/lib/models/auth/biometric_status.model.dart @@ -1,32 +1,10 @@ -import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:local_auth/local_auth.dart'; -class BiometricStatus { - final List availableBiometrics; - final bool canAuthenticate; +part 'biometric_status.model.freezed.dart'; - const BiometricStatus({required this.availableBiometrics, required this.canAuthenticate}); - - @override - String toString() => 'BiometricStatus(availableBiometrics: $availableBiometrics, canAuthenticate: $canAuthenticate)'; - - BiometricStatus copyWith({List? availableBiometrics, bool? canAuthenticate}) { - return BiometricStatus( - availableBiometrics: availableBiometrics ?? this.availableBiometrics, - canAuthenticate: canAuthenticate ?? this.canAuthenticate, - ); - } - - @override - bool operator ==(covariant BiometricStatus other) { - if (identical(this, other)) { - return true; - } - final listEquals = const DeepCollectionEquality().equals; - - return listEquals(other.availableBiometrics, availableBiometrics) && other.canAuthenticate == canAuthenticate; - } - - @override - int get hashCode => availableBiometrics.hashCode ^ canAuthenticate.hashCode; +@freezed +abstract class BiometricStatus with _$BiometricStatus { + const factory BiometricStatus({required List availableBiometrics, required bool canAuthenticate}) = + _BiometricStatus; } diff --git a/mobile/lib/models/cast/cast_manager_state.dart b/mobile/lib/models/cast/cast_manager_state.dart index 9727bc7ed8..0fd3dc877b 100644 --- a/mobile/lib/models/cast/cast_manager_state.dart +++ b/mobile/lib/models/cast/cast_manager_state.dart @@ -1,85 +1,18 @@ -import 'dart:convert'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'cast_manager_state.freezed.dart'; enum CastDestinationType { googleCast } enum CastState { idle, playing, paused, buffering } -class CastManagerState { - final bool isCasting; - final String receiverName; - final CastState castState; - final Duration currentTime; - final Duration duration; - - const CastManagerState({ - required this.isCasting, - required this.receiverName, - required this.castState, - required this.currentTime, - required this.duration, - }); - - CastManagerState copyWith({ - bool? isCasting, - String? receiverName, - CastState? castState, - Duration? currentTime, - Duration? duration, - }) { - return CastManagerState( - isCasting: isCasting ?? this.isCasting, - receiverName: receiverName ?? this.receiverName, - castState: castState ?? this.castState, - currentTime: currentTime ?? this.currentTime, - duration: duration ?? this.duration, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'isCasting': isCasting}); - result.addAll({'receiverName': receiverName}); - result.addAll({'castState': castState}); - result.addAll({'currentTime': currentTime.inSeconds}); - result.addAll({'duration': duration.inSeconds}); - - return result; - } - - factory CastManagerState.fromMap(Map map) { - return CastManagerState( - isCasting: map['isCasting'] ?? false, - receiverName: map['receiverName'] ?? '', - castState: map['castState'] ?? CastState.idle, - currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0), - duration: Duration(seconds: map['duration']?.toInt() ?? 0), - ); - } - - String toJson() => json.encode(toMap()); - - factory CastManagerState.fromJson(String source) => CastManagerState.fromMap(json.decode(source)); - - @override - String toString() => - 'CastManagerState(isCasting: $isCasting, receiverName: $receiverName, castState: $castState, currentTime: $currentTime, duration: $duration)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is CastManagerState && - other.isCasting == isCasting && - other.receiverName == receiverName && - other.castState == castState && - other.currentTime == currentTime && - other.duration == duration; - } - - @override - int get hashCode => - isCasting.hashCode ^ receiverName.hashCode ^ castState.hashCode ^ currentTime.hashCode ^ duration.hashCode; +@freezed +abstract class CastManagerState with _$CastManagerState { + const factory CastManagerState({ + required bool isCasting, + required String receiverName, + required CastState castState, + required Duration currentTime, + required Duration duration, + }) = _CastManagerState; } diff --git a/mobile/lib/models/download/download_state.model.dart b/mobile/lib/models/download/download_state.model.dart index bed92d98b6..5f04fcdfd4 100644 --- a/mobile/lib/models/download/download_state.model.dart +++ b/mobile/lib/models/download/download_state.model.dart @@ -1,88 +1,19 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first -import 'dart:convert'; - -import 'package:background_downloader/background_downloader.dart'; -import 'package:collection/collection.dart'; - -class DownloadInfo { - final String fileName; - final double progress; - // enum - final TaskStatus status; - - const DownloadInfo({required this.fileName, required this.progress, required this.status}); - - DownloadInfo copyWith({String? fileName, double? progress, TaskStatus? status}) { - return DownloadInfo( - fileName: fileName ?? this.fileName, - progress: progress ?? this.progress, - status: status ?? this.status, - ); - } - - Map toMap() { - return {'fileName': fileName, 'progress': progress, 'status': status.index}; - } - - factory DownloadInfo.fromMap(Map map) { - return DownloadInfo( - fileName: map['fileName'] as String, - progress: map['progress'] as double, - status: TaskStatus.values[map['status'] as int], - ); - } - - String toJson() => json.encode(toMap()); - - factory DownloadInfo.fromJson(String source) => DownloadInfo.fromMap(json.decode(source) as Map); - - @override - String toString() => 'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)'; - - @override - bool operator ==(covariant DownloadInfo other) { - if (identical(this, other)) { - return true; - } - - return other.fileName == fileName && other.progress == progress && other.status == status; - } - - @override - int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode; -} - -class DownloadState { - // enum - final TaskStatus downloadStatus; - final Map taskProgress; - final bool showProgress; - const DownloadState({required this.downloadStatus, required this.taskProgress, required this.showProgress}); - - DownloadState copyWith({TaskStatus? downloadStatus, Map? taskProgress, bool? showProgress}) { - return DownloadState( - downloadStatus: downloadStatus ?? this.downloadStatus, - taskProgress: taskProgress ?? this.taskProgress, - showProgress: showProgress ?? this.showProgress, - ); - } - - @override - String toString() => - 'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)'; - - @override - bool operator ==(covariant DownloadState other) { - if (identical(this, other)) { - return true; - } - final mapEquals = const DeepCollectionEquality().equals; - - return other.downloadStatus == downloadStatus && - mapEquals(other.taskProgress, taskProgress) && - other.showProgress == showProgress; - } - - @override - int get hashCode => downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode; -} +import 'package:background_downloader/background_downloader.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'download_state.model.freezed.dart'; + +@freezed +abstract class DownloadInfo with _$DownloadInfo { + const factory DownloadInfo({required String fileName, required double progress, required TaskStatus status}) = + _DownloadInfo; +} + +@freezed +abstract class DownloadState with _$DownloadState { + const factory DownloadState({ + required TaskStatus downloadStatus, + required Map taskProgress, + required bool showProgress, + }) = _DownloadState; +} diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart index 228ad707da..833a9ffca7 100644 --- a/mobile/lib/models/download/livephotos_medatada.model.dart +++ b/mobile/lib/models/download/livephotos_medatada.model.dart @@ -1,4 +1,3 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; enum LivePhotosPart { video, image } diff --git a/mobile/lib/models/map/map_marker.model.dart b/mobile/lib/models/map/map_marker.model.dart index d730f9bd6d..4093747e84 100644 --- a/mobile/lib/models/map/map_marker.model.dart +++ b/mobile/lib/models/map/map_marker.model.dart @@ -1,29 +1,14 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; -class MapMarker { - final LatLng latLng; - final String assetRemoteId; - const MapMarker({required this.latLng, required this.assetRemoteId}); +part 'map_marker.model.freezed.dart'; - MapMarker copyWith({LatLng? latLng, String? assetRemoteId}) { - return MapMarker(latLng: latLng ?? this.latLng, assetRemoteId: assetRemoteId ?? this.assetRemoteId); - } +@freezed +abstract class MapMarker with _$MapMarker { + const factory MapMarker({required LatLng latLng, required String assetRemoteId}) = _MapMarker; - MapMarker.fromDto(MapMarkerResponseDto dto) : latLng = LatLng(dto.lat, dto.lon), assetRemoteId = dto.id; - - @override - String toString() => 'MapMarker(latLng: $latLng, assetRemoteId: $assetRemoteId)'; - - @override - bool operator ==(covariant MapMarker other) { - if (identical(this, other)) { - return true; - } - - return other.latLng == latLng && other.assetRemoteId == assetRemoteId; - } - - @override - int get hashCode => latLng.hashCode ^ assetRemoteId.hashCode; + factory MapMarker.fromDto(MapMarkerResponseDto dto) => + MapMarker(latLng: LatLng(dto.lat, dto.lon), assetRemoteId: dto.id); } diff --git a/mobile/lib/models/map/map_state.model.dart b/mobile/lib/models/map/map_state.model.dart index a4863aa465..f7bd435073 100644 --- a/mobile/lib/models/map/map_state.model.dart +++ b/mobile/lib/models/map/map_state.model.dart @@ -1,79 +1,20 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class MapState { - final ThemeMode themeMode; - final bool showFavoriteOnly; - final bool includeArchived; - final bool withPartners; - final int relativeTime; - final bool shouldRefetchMarkers; - final AsyncValue lightStyleFetched; - final AsyncValue darkStyleFetched; +part 'map_state.model.freezed.dart'; - const MapState({ - this.themeMode = ThemeMode.system, - this.showFavoriteOnly = false, - this.includeArchived = false, - this.withPartners = false, - this.relativeTime = 0, - this.shouldRefetchMarkers = false, - this.lightStyleFetched = const AsyncLoading(), - this.darkStyleFetched = const AsyncLoading(), - }); - - MapState copyWith({ - ThemeMode? themeMode, - bool? showFavoriteOnly, - bool? includeArchived, - bool? withPartners, - int? relativeTime, - bool? shouldRefetchMarkers, - AsyncValue? lightStyleFetched, - AsyncValue? darkStyleFetched, - }) { - return MapState( - themeMode: themeMode ?? this.themeMode, - showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly, - includeArchived: includeArchived ?? this.includeArchived, - withPartners: withPartners ?? this.withPartners, - relativeTime: relativeTime ?? this.relativeTime, - shouldRefetchMarkers: shouldRefetchMarkers ?? this.shouldRefetchMarkers, - lightStyleFetched: lightStyleFetched ?? this.lightStyleFetched, - darkStyleFetched: darkStyleFetched ?? this.darkStyleFetched, - ); - } - - @override - String toString() { - return 'MapState(themeMode: $themeMode, showFavoriteOnly: $showFavoriteOnly, includeArchived: $includeArchived, withPartners: $withPartners, relativeTime: $relativeTime, shouldRefetchMarkers: $shouldRefetchMarkers, lightStyleFetched: $lightStyleFetched, darkStyleFetched: $darkStyleFetched)'; - } - - @override - bool operator ==(covariant MapState other) { - if (identical(this, other)) { - return true; - } - - return other.themeMode == themeMode && - other.showFavoriteOnly == showFavoriteOnly && - other.includeArchived == includeArchived && - other.withPartners == withPartners && - other.relativeTime == relativeTime && - other.shouldRefetchMarkers == shouldRefetchMarkers && - other.lightStyleFetched == lightStyleFetched && - other.darkStyleFetched == darkStyleFetched; - } - - @override - int get hashCode { - return themeMode.hashCode ^ - showFavoriteOnly.hashCode ^ - includeArchived.hashCode ^ - withPartners.hashCode ^ - relativeTime.hashCode ^ - shouldRefetchMarkers.hashCode ^ - lightStyleFetched.hashCode ^ - darkStyleFetched.hashCode; - } +@freezed +abstract class MapState with _$MapState { + const factory MapState({ + @Default(ThemeMode.system) ThemeMode themeMode, + @Default(false) bool showFavoriteOnly, + @Default(false) bool includeArchived, + @Default(false) bool withPartners, + @Default(0) int relativeTime, + @Default(false) bool shouldRefetchMarkers, + @Default(AsyncLoading()) AsyncValue lightStyleFetched, + @Default(AsyncLoading()) AsyncValue darkStyleFetched, + }) = _MapState; } diff --git a/mobile/lib/models/search/search_curated_content.model.dart b/mobile/lib/models/search/search_curated_content.model.dart index 58c4c73264..8778c9b407 100644 --- a/mobile/lib/models/search/search_curated_content.model.dart +++ b/mobile/lib/models/search/search_curated_content.model.dart @@ -1,54 +1,20 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first -import 'dart:convert'; - -/// A wrapper for [CuratedLocationsResponseDto] objects -/// and [CuratedObjectsResponseDto] to be displayed in -/// a view -class SearchCuratedContent { - /// The label to show associated with this curated object - final String label; - - /// The subtitle to show below the label - final String? subtitle; - - /// The id to lookup the asset from the server - final String id; - - const SearchCuratedContent({required this.label, required this.id, this.subtitle}); - - SearchCuratedContent copyWith({String? label, String? subtitle, String? id}) { - return SearchCuratedContent(label: label ?? this.label, subtitle: subtitle ?? this.subtitle, id: id ?? this.id); - } - - Map toMap() { - return {'label': label, 'subtitle': subtitle, 'id': id}; - } - - factory SearchCuratedContent.fromMap(Map map) { - return SearchCuratedContent( - label: map['label'] as String, - subtitle: map['subtitle'] as String?, - id: map['id'] as String, - ); - } - - String toJson() => json.encode(toMap()); - - factory SearchCuratedContent.fromJson(String source) => - SearchCuratedContent.fromMap(json.decode(source) as Map); - - @override - String toString() => 'CuratedContent(label: $label, subtitle: $subtitle, id: $id)'; - - @override - bool operator ==(covariant SearchCuratedContent other) { - if (identical(this, other)) { - return true; - } - - return other.label == label && other.subtitle == subtitle && other.id == id; - } - - @override - int get hashCode => label.hashCode ^ id.hashCode; -} +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'search_curated_content.model.freezed.dart'; + +/// A wrapper for [CuratedLocationsResponseDto] objects +/// and [CuratedObjectsResponseDto] to be displayed in +/// a view +@freezed +abstract class SearchCuratedContent with _$SearchCuratedContent { + const factory SearchCuratedContent({ + /// The label to show associated with this curated object + required String label, + + /// The id to lookup the asset from the server + required String id, + + /// The subtitle to show below the label + String? subtitle, + }) = _SearchCuratedContent; +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 825acb32c6..03660204b9 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -1,10 +1,12 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/utils/option.dart'; +part 'search_filter.model.freezed.dart'; + class SearchLocationFilter { String? country; String? state; @@ -177,52 +179,10 @@ class SearchRatingFilter { int get hashCode => rating.hashCode; } -class SearchDisplayFilters { - bool isNotInAlbum = false; - bool isArchive = false; - bool isFavorite = false; - SearchDisplayFilters({required this.isNotInAlbum, required this.isArchive, required this.isFavorite}); - - SearchDisplayFilters copyWith({bool? isNotInAlbum, bool? isArchive, bool? isFavorite}) { - return SearchDisplayFilters( - isNotInAlbum: isNotInAlbum ?? this.isNotInAlbum, - isArchive: isArchive ?? this.isArchive, - isFavorite: isFavorite ?? this.isFavorite, - ); - } - - Map toMap() { - return {'isNotInAlbum': isNotInAlbum, 'isArchive': isArchive, 'isFavorite': isFavorite}; - } - - factory SearchDisplayFilters.fromMap(Map map) { - return SearchDisplayFilters( - isNotInAlbum: map['isNotInAlbum'] as bool, - isArchive: map['isArchive'] as bool, - isFavorite: map['isFavorite'] as bool, - ); - } - - String toJson() => json.encode(toMap()); - - factory SearchDisplayFilters.fromJson(String source) => - SearchDisplayFilters.fromMap(json.decode(source) as Map); - - @override - String toString() => - 'SearchDisplayFilters(isNotInAlbum: $isNotInAlbum, isArchive: $isArchive, isFavorite: $isFavorite)'; - - @override - bool operator ==(covariant SearchDisplayFilters other) { - if (identical(this, other)) { - return true; - } - - return other.isNotInAlbum == isNotInAlbum && other.isArchive == isArchive && other.isFavorite == isFavorite; - } - - @override - int get hashCode => isNotInAlbum.hashCode ^ isArchive.hashCode ^ isFavorite.hashCode; +@freezed +abstract class SearchDisplayFilters with _$SearchDisplayFilters { + const factory SearchDisplayFilters({required bool isNotInAlbum, required bool isArchive, required bool isFavorite}) = + _SearchDisplayFilters; } class SearchFilter { @@ -233,7 +193,7 @@ class SearchFilter { String? language; String? assetId; List? tagIds; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -289,7 +249,7 @@ class SearchFilter { String? language, String? ocr, String? assetId, - Set? people, + Set? people, List? tagIds, SearchLocationFilter? location, SearchCameraFilter? camera, diff --git a/mobile/lib/models/server_info/server_disk_info.model.dart b/mobile/lib/models/server_info/server_disk_info.model.dart index 16e58b331d..910f06b065 100644 --- a/mobile/lib/models/server_info/server_disk_info.model.dart +++ b/mobile/lib/models/server_info/server_disk_info.model.dart @@ -1,53 +1,22 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:openapi/api.dart'; -class ServerDiskInfo { - final String diskAvailable; - final String diskSize; - final String diskUse; - final double diskUsagePercentage; +part 'server_disk_info.model.freezed.dart'; - const ServerDiskInfo({ - required this.diskAvailable, - required this.diskSize, - required this.diskUse, - required this.diskUsagePercentage, - }); +@freezed +abstract class ServerDiskInfo with _$ServerDiskInfo { + const factory ServerDiskInfo({ + required String diskAvailable, + required String diskSize, + required String diskUse, + required double diskUsagePercentage, + }) = _ServerDiskInfo; - ServerDiskInfo copyWith({String? diskAvailable, String? diskSize, String? diskUse, double? diskUsagePercentage}) { - return ServerDiskInfo( - diskAvailable: diskAvailable ?? this.diskAvailable, - diskSize: diskSize ?? this.diskSize, - diskUse: diskUse ?? this.diskUse, - diskUsagePercentage: diskUsagePercentage ?? this.diskUsagePercentage, - ); - } - - @override - String toString() { - return 'ServerDiskInfo(diskAvailable: $diskAvailable, diskSize: $diskSize, diskUse: $diskUse, diskUsagePercentage: $diskUsagePercentage)'; - } - - ServerDiskInfo.fromDto(ServerStorageResponseDto dto) - : diskAvailable = dto.diskAvailable, - diskSize = dto.diskSize, - diskUse = dto.diskUse, - diskUsagePercentage = dto.diskUsagePercentage; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is ServerDiskInfo && - other.diskAvailable == diskAvailable && - other.diskSize == diskSize && - other.diskUse == diskUse && - other.diskUsagePercentage == diskUsagePercentage; - } - - @override - int get hashCode { - return diskAvailable.hashCode ^ diskSize.hashCode ^ diskUse.hashCode ^ diskUsagePercentage.hashCode; - } + factory ServerDiskInfo.fromDto(ServerStorageResponseDto dto) => ServerDiskInfo( + diskAvailable: dto.diskAvailable, + diskSize: dto.diskSize, + diskUse: dto.diskUse, + diskUsagePercentage: dto.diskUsagePercentage, + ); } diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart index c288c1bfbf..4de7e2e3cd 100644 --- a/mobile/lib/models/server_info/server_features.model.dart +++ b/mobile/lib/models/server_info/server_features.model.dart @@ -1,74 +1,26 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:openapi/api.dart'; -class ServerFeatures { - final bool trash; - final bool map; - final bool oauthEnabled; - final bool passwordLogin; - final bool ocr; - final bool smartSearch; +part 'server_features.model.freezed.dart'; - const ServerFeatures({ - required this.trash, - required this.map, - required this.oauthEnabled, - required this.passwordLogin, - this.ocr = false, - this.smartSearch = false, - }); +@freezed +abstract class ServerFeatures with _$ServerFeatures { + const factory ServerFeatures({ + required bool trash, + required bool map, + required bool oauthEnabled, + required bool passwordLogin, + @Default(false) bool ocr, + @Default(false) bool smartSearch, + }) = _ServerFeatures; - ServerFeatures copyWith({ - bool? trash, - bool? map, - bool? oauthEnabled, - bool? passwordLogin, - bool? ocr, - bool? smartSearch, - }) { - return ServerFeatures( - trash: trash ?? this.trash, - map: map ?? this.map, - oauthEnabled: oauthEnabled ?? this.oauthEnabled, - passwordLogin: passwordLogin ?? this.passwordLogin, - ocr: ocr ?? this.ocr, - smartSearch: smartSearch ?? this.smartSearch, - ); - } - - @override - String toString() { - return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)'; - } - - ServerFeatures.fromDto(ServerFeaturesDto dto) - : trash = dto.trash, - map = dto.map, - oauthEnabled = dto.oauth, - passwordLogin = dto.passwordLogin, - ocr = dto.ocr, - smartSearch = dto.smartSearch; - - @override - bool operator ==(covariant ServerFeatures other) { - if (identical(this, other)) { - return true; - } - - return other.trash == trash && - other.map == map && - other.oauthEnabled == oauthEnabled && - other.passwordLogin == passwordLogin && - other.ocr == ocr && - other.smartSearch == smartSearch; - } - - @override - int get hashCode { - return trash.hashCode ^ - map.hashCode ^ - oauthEnabled.hashCode ^ - passwordLogin.hashCode ^ - ocr.hashCode ^ - smartSearch.hashCode; - } + factory ServerFeatures.fromDto(ServerFeaturesDto dto) => ServerFeatures( + trash: dto.trash, + map: dto.map, + oauthEnabled: dto.oauth, + passwordLogin: dto.passwordLogin, + ocr: dto.ocr, + smartSearch: dto.smartSearch, + ); } diff --git a/mobile/lib/models/server_info/server_info.model.dart b/mobile/lib/models/server_info/server_info.model.dart index 33d6393e15..1ed7dd6f8b 100644 --- a/mobile/lib/models/server_info/server_info.model.dart +++ b/mobile/lib/models/server_info/server_info.model.dart @@ -1,9 +1,13 @@ -import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/server_info/server_config.model.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/models/server_info/server_features.model.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; +part 'server_info.model.freezed.dart'; + enum VersionStatus { upToDate, clientOutOfDate, @@ -12,74 +16,20 @@ enum VersionStatus { String get message => switch (this) { VersionStatus.upToDate => "", - VersionStatus.clientOutOfDate => "app_update_available".tr(), - VersionStatus.serverOutOfDate => "server_update_available".tr(), - VersionStatus.error => "unable_to_check_version".tr(), + VersionStatus.clientOutOfDate => StaticTranslations.instance.app_update_available, + VersionStatus.serverOutOfDate => StaticTranslations.instance.server_update_available, + VersionStatus.error => StaticTranslations.instance.unable_to_check_version, }; } -class ServerInfo { - final ServerVersion serverVersion; - final ServerVersion? latestVersion; - final ServerFeatures serverFeatures; - final ServerConfig serverConfig; - final ServerDiskInfo serverDiskInfo; - final VersionStatus versionStatus; - - const ServerInfo({ - required this.serverVersion, - this.latestVersion, - required this.serverFeatures, - required this.serverConfig, - required this.serverDiskInfo, - required this.versionStatus, - }); - - ServerInfo copyWith({ - ServerVersion? serverVersion, +@freezed +abstract class ServerInfo with _$ServerInfo { + const factory ServerInfo({ + required ServerVersion serverVersion, ServerVersion? latestVersion, - ServerFeatures? serverFeatures, - ServerConfig? serverConfig, - ServerDiskInfo? serverDiskInfo, - VersionStatus? versionStatus, - }) { - return ServerInfo( - serverVersion: serverVersion ?? this.serverVersion, - latestVersion: latestVersion ?? this.latestVersion, - serverFeatures: serverFeatures ?? this.serverFeatures, - serverConfig: serverConfig ?? this.serverConfig, - serverDiskInfo: serverDiskInfo ?? this.serverDiskInfo, - versionStatus: versionStatus ?? this.versionStatus, - ); - } - - @override - String toString() { - return 'ServerInfo(serverVersion: $serverVersion, latestVersion: $latestVersion, serverFeatures: $serverFeatures, serverConfig: $serverConfig, serverDiskInfo: $serverDiskInfo, versionStatus: $versionStatus)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is ServerInfo && - other.serverVersion == serverVersion && - other.latestVersion == latestVersion && - other.serverFeatures == serverFeatures && - other.serverConfig == serverConfig && - other.serverDiskInfo == serverDiskInfo && - other.versionStatus == versionStatus; - } - - @override - int get hashCode { - return serverVersion.hashCode ^ - latestVersion.hashCode ^ - serverFeatures.hashCode ^ - serverConfig.hashCode ^ - serverDiskInfo.hashCode ^ - versionStatus.hashCode; - } + required ServerFeatures serverFeatures, + required ServerConfig serverConfig, + required ServerDiskInfo serverDiskInfo, + required VersionStatus versionStatus, + }) = _ServerInfo; } diff --git a/mobile/lib/models/server_info/server_version.model.dart b/mobile/lib/models/server_info/server_version.model.dart index 40f35a3cd0..cf16d4db0b 100644 --- a/mobile/lib/models/server_info/server_version.model.dart +++ b/mobile/lib/models/server_info/server_version.model.dart @@ -6,8 +6,4 @@ class ServerVersion extends SemVer { ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease); - - bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) { - return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease); - } } diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart index e7b65a96ef..cf96609d3c 100644 --- a/mobile/lib/models/shared_link/shared_link.model.dart +++ b/mobile/lib/models/shared_link/shared_link.model.dart @@ -1,119 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:openapi/api.dart'; +part 'shared_link.model.freezed.dart'; + enum SharedLinkSource { album, individual } -class SharedLink { - final String id; - final String title; - final bool allowDownload; - final bool allowUpload; - final String? thumbAssetId; - final String? description; - final String? password; - final DateTime? expiresAt; - final String key; - final bool showMetadata; - final SharedLinkSource type; - final String? slug; +@freezed +abstract class SharedLink with _$SharedLink { + const factory SharedLink({ + required String id, + required String title, + required bool allowDownload, + required bool allowUpload, + required String? thumbAssetId, + required String? description, + required String? password, + required DateTime? expiresAt, + required String key, + required bool showMetadata, + required SharedLinkSource type, + required String? slug, + }) = _SharedLink; - const SharedLink({ - required this.id, - required this.title, - required this.allowDownload, - required this.allowUpload, - required this.thumbAssetId, - required this.description, - required this.password, - required this.expiresAt, - required this.key, - required this.showMetadata, - required this.type, - required this.slug, - }); - - SharedLink copyWith({ - String? id, - String? title, - String? thumbAssetId, - bool? allowDownload, - bool? allowUpload, - String? description, - String? password, - DateTime? expiresAt, - String? key, - bool? showMetadata, - SharedLinkSource? type, - String? slug, - }) { + factory SharedLink.fromDto(SharedLinkResponseDto dto) { + final isAlbum = dto.type == SharedLinkType.ALBUM; return SharedLink( - id: id ?? this.id, - title: title ?? this.title, - thumbAssetId: thumbAssetId ?? this.thumbAssetId, - allowDownload: allowDownload ?? this.allowDownload, - allowUpload: allowUpload ?? this.allowUpload, - description: description ?? this.description, - password: password ?? this.password, - expiresAt: expiresAt ?? this.expiresAt, - key: key ?? this.key, - showMetadata: showMetadata ?? this.showMetadata, - type: type ?? this.type, - slug: slug ?? this.slug, - ); - } - - SharedLink.fromDto(SharedLinkResponseDto dto) - : id = dto.id, - allowDownload = dto.allowDownload, - allowUpload = dto.allowUpload, - description = dto.description, - password = dto.password, - expiresAt = dto.expiresAt, - key = dto.key, - showMetadata = dto.showMetadata, - slug = dto.slug, - type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual, - title = dto.type == SharedLinkType.ALBUM - ? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE" - : "INDIVIDUAL SHARE", - thumbAssetId = dto.type == SharedLinkType.ALBUM + id: dto.id, + allowDownload: dto.allowDownload, + allowUpload: dto.allowUpload, + description: dto.description, + password: dto.password, + expiresAt: dto.expiresAt, + key: dto.key, + showMetadata: dto.showMetadata, + slug: dto.slug, + type: isAlbum ? SharedLinkSource.album : SharedLinkSource.individual, + title: isAlbum ? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE" : "INDIVIDUAL SHARE", + thumbAssetId: isAlbum ? dto.album.orElse(null)?.albumThumbnailAssetId : dto.assets.isNotEmpty ? dto.assets[0].id - : null; - - @override - String toString() => - 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)'; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SharedLink && - other.id == id && - other.title == title && - other.thumbAssetId == thumbAssetId && - other.allowDownload == allowDownload && - other.allowUpload == allowUpload && - other.description == description && - other.password == password && - other.expiresAt == expiresAt && - other.key == key && - other.showMetadata == showMetadata && - other.type == type && - other.slug == slug; - - @override - int get hashCode => - id.hashCode ^ - title.hashCode ^ - thumbAssetId.hashCode ^ - allowDownload.hashCode ^ - allowUpload.hashCode ^ - description.hashCode ^ - password.hashCode ^ - expiresAt.hashCode ^ - key.hashCode ^ - showMetadata.hashCode ^ - type.hashCode ^ - slug.hashCode; + : null, + ); + } } diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart index 0f9c3e4c29..3643020bc8 100644 --- a/mobile/lib/models/upload/share_intent_attachment.model.dart +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -1,4 +1,3 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; import 'dart:io'; diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index cc618dbe60..a0e55e58de 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; @@ -10,7 +9,6 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; @@ -81,8 +79,8 @@ class _DriftBackupPageState extends ConsumerState { final error = ref.watch(driftBackupProvider.select((p) => p.error)); - final backupNotifier = ref.read(driftBackupProvider.notifier); - final backupSyncManager = ref.read(backgroundSyncProvider); + final backupNotifier = ref.watch(driftBackupProvider.notifier); + final backupSyncManager = ref.watch(backgroundSyncProvider); Future startBackup() async { final currentUser = Store.tryGet(StoreKey.currentUser); @@ -108,7 +106,7 @@ class _DriftBackupPageState extends ConsumerState { return Scaffold( appBar: AppBar( elevation: 0, - title: Text("backup_controller_page_backup".t()), + title: Text(context.t.backup_controller_page_backup), leading: IconButton( onPressed: () { unawaited(context.maybePop(true)); @@ -122,7 +120,7 @@ class _DriftBackupPageState extends ConsumerState { unawaited(context.pushRoute(const DriftBackupOptionsRoute())); }, icon: const Icon(Icons.settings_outlined), - tooltip: "backup_options".t(context: context), + tooltip: context.t.backup_options, ), ], ), @@ -143,7 +141,7 @@ class _DriftBackupPageState extends ConsumerState { onStart: () async => await startBackup(), onStop: () { syncSuccess = null; - backupNotifier.stopForegroundBackup(); + backupNotifier.stopForegroundBackup(reason: "backup button toggled off"); }, ), switch (error) { @@ -318,17 +316,16 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final backupAlbums = ref.watch(backupAlbumProvider); + Widget buildSelectedAlbumName() { - String text = "backup_controller_page_backup_selected".tr(); - final albums = ref - .watch(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.selected) - .toList(); + String text = context.t.backup_controller_page_backup_selected; + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.selected).toList(); if (albums.isNotEmpty) { for (var album in albums) { if (album.name == "Recent" || album.name == "Recents") { - text += "${album.name} (${'all'.tr()}), "; + text += "${album.name} (${context.t.all}), "; } else { text += "${album.name}, "; } @@ -345,7 +342,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { return Padding( padding: const EdgeInsets.only(top: 8.0), child: Text( - "backup_controller_page_none_selected".tr(), + context.t.backup_controller_page_none_selected, style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor), ), ); @@ -353,11 +350,8 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { } Widget buildExcludedAlbumName() { - String text = "backup_controller_page_excluded".tr(); - final albums = ref - .watch(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.excluded) - .toList(); + String text = context.t.backup_controller_page_excluded; + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); if (albums.isNotEmpty) { for (var album in albums) { @@ -385,16 +379,16 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { borderOnForeground: false, child: ListTile( minVerticalPadding: 18, - title: Text("backup_controller_page_albums", style: context.textTheme.titleMedium).tr(), + title: Text(context.t.backup_controller_page_albums, style: context.textTheme.titleMedium), subtitle: Padding( padding: const EdgeInsets.only(top: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "backup_controller_page_to_backup", + context.t.backup_controller_page_to_backup, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ).tr(), + ), buildSelectedAlbumName(), buildExcludedAlbumName(), ], @@ -403,13 +397,17 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { trailing: ElevatedButton( onPressed: () async { await context.pushRoute(const DriftBackupAlbumSelectionRoute()); + if (!context.mounted) { + return; + } + final currentUser = ref.read(currentUserProvider); if (currentUser == null) { return; } unawaited(ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id)); }, - child: const Text("select", style: TextStyle(fontWeight: FontWeight.bold)).tr(), + child: Text(context.t.select, style: const TextStyle(fontWeight: FontWeight.bold)), ), ), ); @@ -424,8 +422,8 @@ class _TotalCard extends ConsumerWidget { final totalCount = ref.watch(driftBackupProvider.select((p) => p.totalCount)); return BackupInfoCard( - title: "total".tr(), - subtitle: "backup_controller_page_total_sub".tr(), + title: context.t.total, + subtitle: context.t.backup_controller_page_total_sub, info: totalCount.toString(), ); } @@ -440,8 +438,8 @@ class _BackupCard extends ConsumerWidget { final syncStatus = ref.watch(syncStatusProvider); return BackupInfoCard( - title: "backup_controller_page_backup".tr(), - subtitle: "backup_controller_page_backup_sub".tr(), + title: context.t.backup_controller_page_backup, + subtitle: context.t.backup_controller_page_backup_sub, info: backupCount.toString(), isLoading: syncStatus.isRemoteSyncing, ); @@ -468,11 +466,11 @@ class _RemainderCard extends ConsumerWidget { ListTile( minVerticalPadding: 18, isThreeLine: true, - title: Text("backup_controller_page_remainder".t(context: context), style: context.textTheme.titleMedium), + title: Text(context.t.backup_controller_page_remainder, style: context.textTheme.titleMedium), subtitle: Padding( padding: const EdgeInsets.only(top: 4.0, right: 18.0), child: Text( - "backup_controller_page_remainder_sub".t(context: context), + context.t.backup_controller_page_remainder_sub, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), ), @@ -505,11 +503,11 @@ class _RemainderCard extends ConsumerWidget { ], ), Text( - "backup_info_card_assets", + context.t.backup_info_card_assets, style: context.textTheme.labelLarge?.copyWith( color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255), ), - ).tr(), + ), ], ), ), @@ -526,7 +524,7 @@ class _RemainderCard extends ConsumerWidget { ), onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()), title: Text( - "view_details".t(context: context), + context.t.view_details, style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)), ), trailing: Icon(Icons.arrow_forward_ios, size: 16, color: context.colorScheme.onSurfaceVariant), @@ -562,6 +560,11 @@ class _PreparingStatusState extends ConsumerState { final currentUser = ref.read(currentUserProvider); if (currentUser != null && mounted) { await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); + if (!context.mounted) { + timer.cancel(); + _pollingTimer = null; + return; + } // Stop polling if processing count reaches 0 final updatedProcessingCount = ref.read(driftBackupProvider.select((p) => p.processingCount)); @@ -613,7 +616,7 @@ class _PreparingStatusState extends ConsumerState { Row( children: [ Text( - "preparing".t(context: context), + context.t.preparing, style: context.textTheme.labelLarge?.copyWith( color: context.colorScheme.onSurface.withAlpha(200), ), @@ -643,7 +646,7 @@ class _PreparingStatusState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - "ready_for_upload".t(context: context), + context.t.ready_for_upload, style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)), ), const SizedBox(height: 2), diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 7667dbc3f1..26719c2884 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -1,13 +1,12 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; @@ -102,6 +101,9 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount)); final totalChanged = currentTotalAssetCount != _initialTotalAssetCount; final backupNotifier = ref.read(driftBackupProvider.notifier); @@ -119,7 +125,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets())); if (isBackupEnabled) { - backupNotifier.stopForegroundBackup(); + backupNotifier.stopForegroundBackup(reason: "backup albums updated"); unawaited( backgroundSync.syncRemote().then((success) { if (success) { @@ -147,13 +153,13 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState setState(() => _searchQuery = value.trim()), ) - : const Text("backup_album_selection_page_select_albums").t(context: context), + : Text(context.t.backup_album_selection_page_select_albums), actions: [ if (!_isSearchMode) IconButton( @@ -187,9 +193,9 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState> result = ref.watch(driftBackupCandidateProvider); return Scaffold( - appBar: AppBar(title: Text('backup_controller_page_remainder'.t(context: context))), + appBar: AppBar(title: Text(context.t.backup_controller_page_remainder)), body: result.when( data: (List candidates) { return ListView.separated( @@ -66,7 +65,7 @@ class DriftBackupAssetDetailPage extends ConsumerWidget { ); }, error: (error, stackTrace) => Text( - 'error_saving_image'.tr(args: [error.toString()]), + context.t.error_saving_image(error: error.toString()), style: TextStyle(color: context.colorScheme.error), ), loading: () => @@ -102,7 +101,7 @@ class DriftBackupAssetDetailPage extends ConsumerWidget { ); }, error: (Object error, StackTrace stackTrace) { - return Center(child: Text('error_saving_image'.tr(args: [error.toString()]))); + return Center(child: Text(context.t.error_saving_image(error: error.toString()))); }, loading: () { return const SizedBox(height: 48, width: 48, child: Center(child: CircularProgressIndicator.adaptive())); diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart index ef4974d4dd..994f09766c 100644 --- a/mobile/lib/pages/backup/drift_backup_options.page.dart +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; @@ -19,7 +19,7 @@ class DriftBackupOptionsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { bool hasPopped = false; - final previousBackup = ref.read(appConfigProvider).backup; + final previousBackup = ref.watch(appConfigProvider.select((s) => s.backup)); final previousCellularForVideos = previousBackup.useCellularForVideos; final previousCellularForPhotos = previousBackup.useCellularForPhotos; return PopScope( @@ -51,15 +51,12 @@ class DriftBackupOptionsPage extends ConsumerWidget { } ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("network_requirements_updated".t(context: context)), - duration: const Duration(seconds: 4), - ), + SnackBar(content: Text(context.t.network_requirements_updated), duration: const Duration(seconds: 4)), ); final backupNotifier = ref.read(driftBackupProvider.notifier); final backgroundSync = ref.read(backgroundSyncProvider); - backupNotifier.stopForegroundBackup(); + backupNotifier.stopForegroundBackup(reason: "backup settings updated"); unawaited( backgroundSync.syncRemote().then((success) { if (success) { @@ -72,7 +69,7 @@ class DriftBackupOptionsPage extends ConsumerWidget { } }, child: Scaffold( - appBar: AppBar(title: Text("backup_options".t(context: context))), + appBar: AppBar(title: Text(context.t.backup_options)), body: const DriftBackupSettings(), ), ); diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index 5ed43672d9..764cb5dcf1 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:path/path.dart' as path; @@ -80,7 +80,7 @@ class _DriftUploadDetailPageState extends ConsumerState { return Scaffold( appBar: AppBar( - title: Text("upload_details".t(context: context)), + title: Text(context.t.upload_details), backgroundColor: context.colorScheme.surface, elevation: 0, scrolledUnderElevation: 1, @@ -125,7 +125,7 @@ class _DriftUploadDetailPageState extends ConsumerState { SliverToBoxAdapter( child: _buildSectionHeader( context, - title: "uploading".t(context: context), + title: context.t.uploading, count: uploadingItems.length, color: context.colorScheme.primary, ), @@ -151,7 +151,7 @@ class _DriftUploadDetailPageState extends ConsumerState { SliverToBoxAdapter( child: _buildSectionHeader( context, - title: "errors_text".t(context: context), + title: context.t.errors_text, count: failedItems.length, color: context.colorScheme.error, ), @@ -231,7 +231,7 @@ class _DriftUploadDetailPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "downloading_from_icloud".t(context: context), + context.t.downloading_from_icloud, style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -318,7 +318,7 @@ class _DriftUploadDetailPageState extends ConsumerState { const SizedBox(height: 2), Text( isFailed - ? item.error ?? "unable_to_upload_file".t(context: context) + ? item.error ?? context.t.errors.unable_to_upload_file : "${formatHumanReadableBytes(item.fileSize, 1)} • ${item.networkSpeedAsString}", style: context.textTheme.labelLarge?.copyWith( color: isFailed @@ -395,7 +395,7 @@ class _DriftUploadDetailPageState extends ConsumerState { ), const SizedBox(height: 4), Text( - item.error ?? "unable_to_upload_file".t(context: context), + item.error ?? context.t.errors.unable_to_upload_file, style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error), maxLines: 4, overflow: TextOverflow.ellipsis, @@ -530,7 +530,7 @@ class _CurrentUploadThumbnail extends ConsumerWidget { Future _getAsset(WidgetRef ref) async { try { - return await ref.read(localAssetRepository).getById(taskId); + return await ref.read(driftProvider).localAssetRepository.getById(taskId); } catch (e) { return null; } @@ -556,7 +556,7 @@ class FileDetailDialog extends ConsumerWidget { const SizedBox(width: 8), Expanded( child: Text( - "details".t(context: context), + context.t.details, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor), ), ), @@ -595,20 +595,14 @@ class FileDetailDialog extends ConsumerWidget { const SizedBox(height: 24), if (asset != null) _buildInfoSection(context, [ - _buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)), - _buildInfoRow(context, "local_id".t(context: context), asset.id), - _buildInfoRow( - context, - "file_size".t(context: context), - formatHumanReadableBytes(uploadStatus.fileSize, 2), - ), - if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"), - if (asset.height != null) - _buildInfoRow(context, "height".t(context: context), "${asset.height}px"), - _buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()), - _buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()), - if (asset.checksum != null) - _buildInfoRow(context, "checksum".t(context: context), asset.checksum!), + _buildInfoRow(context, context.t.filename, path.basename(uploadStatus.filename)), + _buildInfoRow(context, context.t.local_id, asset.id), + _buildInfoRow(context, context.t.file_size, formatHumanReadableBytes(uploadStatus.fileSize, 2)), + if (asset.width != null) _buildInfoRow(context, context.t.width, "${asset.width}px"), + if (asset.height != null) _buildInfoRow(context, context.t.height, "${asset.height}px"), + _buildInfoRow(context, context.t.created_at, asset.createdAt.toString()), + _buildInfoRow(context, context.t.updated_at, asset.updatedAt.toString()), + if (asset.checksum != null) _buildInfoRow(context, context.t.checksum, asset.checksum!), ]), ], ), @@ -620,7 +614,7 @@ class FileDetailDialog extends ConsumerWidget { TextButton( onPressed: () => Navigator.of(context).pop(), child: Text( - "close".t(), + context.t.close, style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor), ), ), @@ -667,7 +661,7 @@ class FileDetailDialog extends ConsumerWidget { Future _getAssetDetails(WidgetRef ref, String localAssetId) async { try { - return await ref.read(localAssetRepository).getById(localAssetId); + return await ref.read(driftProvider).localAssetRepository.getById(localAssetId); } catch (e) { return null; } diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index b04d8dc926..d0b5096288 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -1,23 +1,23 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; +import 'package:intl/intl.dart'; @RoutePage() -class AppLogPage extends HookConsumerWidget { +class AppLogPage extends HookWidget { const AppLogPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final immichLogger = LogService.I; final shouldReload = useState(false); final logMessages = useFuture(useMemoized(() => immichLogger.getMessages(), [shouldReload.value])); @@ -51,7 +51,7 @@ class AppLogPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: Text('logs'.tr(), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0)), + title: Text(context.t.logs, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0)), scrolledUnderElevation: 1, elevation: 2, actions: [ diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart index 52f792df9d..8e7eaf3ba8 100644 --- a/mobile/lib/pages/common/app_log_detail.page.dart +++ b/mobile/lib/pages/common/app_log_detail.page.dart @@ -1,21 +1,21 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; @RoutePage() -class AppLogDetailPage extends HookConsumerWidget { +class AppLogDetailPage extends HookWidget { const AppLogDetailPage({super.key, required this.logMessage}); final LogMessage logMessage; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { Padding buildTextWithCopyButton(String header, String text) { return Padding( padding: const EdgeInsets.all(8.0), @@ -44,7 +44,7 @@ class AppLogDetailPage extends HookConsumerWidget { context.scaffoldMessenger.showSnackBar( SnackBar( content: Text( - "copied_to_clipboard".tr(), + context.t.copied_to_clipboard, style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), ), ), @@ -106,7 +106,7 @@ class AppLogDetailPage extends HookConsumerWidget { } return Scaffold( - appBar: AppBar(title: Text("log_detail_title".tr())), + appBar: AppBar(title: Text(context.t.log_detail_title)), body: SafeArea( child: ListView( children: [ diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart index 267d32fc3d..2cf7d037a3 100644 --- a/mobile/lib/pages/common/download_panel.dart +++ b/mobile/lib/pages/common/download_panel.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'package:background_downloader/background_downloader.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; class DownloadPanel extends ConsumerWidget { @@ -17,7 +17,7 @@ class DownloadPanel extends ConsumerWidget { final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList(); void onCancelDownload(String id) { - unawaited(ref.watch(downloadStateProvider.notifier).cancelDownload(id)); + unawaited(ref.read(downloadStateProvider.notifier).cancelDownload(id)); } return Positioned( @@ -64,17 +64,18 @@ class DownloadTaskTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isComplete = status == TaskStatus.complete; final progressPercent = (progress * 100).round(); String getStatusText() => switch (status) { - TaskStatus.running => 'downloading'.tr(), - TaskStatus.complete => 'download_complete'.tr(), - TaskStatus.failed => 'download_failed'.tr(), - TaskStatus.canceled => 'download_canceled'.tr(), - TaskStatus.paused => 'download_paused'.tr(), - TaskStatus.enqueued => 'download_enqueue'.tr(), - TaskStatus.notFound => 'download_notfound'.tr(), - TaskStatus.waitingToRetry => 'download_waiting_to_retry'.tr(), + TaskStatus.running => context.t.downloading, + TaskStatus.complete => context.t.download_complete, + TaskStatus.failed => context.t.download_failed, + TaskStatus.canceled => context.t.download_canceled, + TaskStatus.paused => context.t.download_paused, + TaskStatus.enqueued => context.t.download_enqueue, + TaskStatus.notFound => context.t.download_notfound, + TaskStatus.waitingToRetry => context.t.download_waiting_to_retry, }; return SizedBox( @@ -88,9 +89,14 @@ class DownloadTaskTile extends StatelessWidget { leading: const Icon(Icons.video_file_outlined), title: Text(getStatusText(), style: context.textTheme.labelLarge), trailing: IconButton( - icon: Icon(Icons.close, color: context.colorScheme.onError), + icon: Icon( + isComplete ? Icons.download_done : Icons.close, + color: isComplete ? context.colorScheme.primary : context.colorScheme.onError, + ), onPressed: onCancelDownload, - style: ElevatedButton.styleFrom(backgroundColor: context.colorScheme.error.withAlpha(200)), + style: isComplete + ? null + : ElevatedButton.styleFrom(backgroundColor: context.colorScheme.error.withAlpha(200)), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index f0b3f4b67f..c934ca6a46 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -1,5 +1,4 @@ import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -21,7 +20,7 @@ class HeaderSettingsPage extends HookConsumerWidget { final headers = useState>([]); final setInitialHeaders = useState(false); - final storedHeaders = ref.read(appConfigProvider).network.customHeaders; + final storedHeaders = ref.watch(appConfigProvider.select((s) => s.network.customHeaders)); if (!setInitialHeaders.value) { storedHeaders.forEach((k, v) { final header = SettingsHeader(); @@ -64,7 +63,7 @@ class HeaderSettingsPage extends HookConsumerWidget { headers.value = headers.value.toList(); }, icon: const Icon(Icons.add_outlined), - tooltip: 'header_settings_add_header_tip'.tr(), + tooltip: context.t.header_settings_add_header_tip, ), ], ), @@ -93,8 +92,9 @@ class HeaderSettingsPage extends HookConsumerWidget { headersMap[key] = value; } + final apiService = ref.read(apiServiceProvider); await ref.read(settingsProvider).write(.networkCustomHeaders, headersMap); - await ref.read(apiServiceProvider).updateHeaders(); + await apiService.updateHeaders(); } } @@ -110,7 +110,7 @@ class HeaderKeyValueSettings extends StatelessWidget { String? emptyFieldValidator(String? value) { if (value == null || value.isEmpty) { - return 'header_settings_field_validator_msg'.tr(); + return StaticTranslations.instance.header_settings_field_validator_msg; } return null; @@ -128,10 +128,12 @@ class HeaderKeyValueSettings extends StatelessWidget { child: TextFormField( controller: keyController, decoration: InputDecoration( - labelText: 'header_settings_header_name_input'.tr(), + labelText: context.t.header_settings_header_name_input, border: const OutlineInputBorder(), ), autocorrect: false, + smartDashesType: .disabled, + smartQuotesType: .disabled, onChanged: (headerKey) { header.key = headerKey; }, @@ -156,10 +158,12 @@ class HeaderKeyValueSettings extends StatelessWidget { child: TextFormField( controller: valueController, decoration: InputDecoration( - labelText: 'header_settings_header_value_input'.tr(), + labelText: context.t.header_settings_header_value_input, border: const OutlineInputBorder(), ), autocorrect: false, + smartDashesType: .disabled, + smartQuotesType: .disabled, onChanged: (headerValue) { header.value = headerValue; }, diff --git a/mobile/lib/pages/common/large_leading_tile.dart b/mobile/lib/pages/common/large_leading_tile.dart index 58528008ca..f8a8c51e17 100644 --- a/mobile/lib/pages/common/large_leading_tile.dart +++ b/mobile/lib/pages/common/large_leading_tile.dart @@ -54,7 +54,7 @@ class LargeLeadingTile extends StatelessWidget { ], ), ), - if (trailing != null) trailing!, + ?trailing, ], ), ), diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index d102fe0d3b..2e941a70a3 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -1,5 +1,4 @@ import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -18,21 +17,45 @@ import 'package:immich_mobile/widgets/settings/preference_settings/preference_se import 'package:immich_mobile/widgets/settings/settings_card.dart'; enum SettingSection { - advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"), - assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"), - backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"), - freeUpSpace('free_up_space', Icons.cleaning_services_outlined, "free_up_space_settings_subtitle"), - languages('language', Icons.language, "setting_languages_subtitle"), - networking('networking_settings', Icons.wifi, "networking_subtitle"), - notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"), - preferences('preferences_settings_title', Icons.interests_outlined, "preferences_settings_subtitle"), - timeline('asset_list_settings_title', Icons.auto_awesome_mosaic_outlined, "asset_list_settings_subtitle"), - beta('sync_status', Icons.sync_outlined, "sync_status_subtitle"); + advanced(Icons.build_outlined), + assetViewer(Icons.image_outlined), + backup(Icons.cloud_upload_outlined), + freeUpSpace(Icons.cleaning_services_outlined), + languages(Icons.language), + networking(Icons.wifi), + notifications(Icons.notifications_none_rounded), + preferences(Icons.interests_outlined), + timeline(Icons.auto_awesome_mosaic_outlined), + beta(Icons.sync_outlined); - final String title; - final String subtitle; final IconData icon; + String title(Translations t) => switch (this) { + SettingSection.advanced => t.advanced, + SettingSection.assetViewer => t.asset_viewer_settings_title, + SettingSection.backup => t.backup, + SettingSection.freeUpSpace => t.free_up_space, + SettingSection.languages => t.language, + SettingSection.networking => t.networking_settings, + SettingSection.notifications => t.notifications, + SettingSection.preferences => t.preferences_settings_title, + SettingSection.timeline => t.asset_list_settings_title, + SettingSection.beta => t.sync_status, + }; + + String subtitle(Translations t) => switch (this) { + SettingSection.advanced => t.advanced_settings_tile_subtitle, + SettingSection.assetViewer => t.asset_viewer_settings_subtitle, + SettingSection.backup => t.backup_settings_subtitle, + SettingSection.freeUpSpace => t.free_up_space_settings_subtitle, + SettingSection.languages => t.setting_languages_subtitle, + SettingSection.networking => t.networking_subtitle, + SettingSection.notifications => t.setting_notifications_subtitle, + SettingSection.preferences => t.preferences_settings_subtitle, + SettingSection.timeline => t.asset_list_settings_subtitle, + SettingSection.beta => t.sync_status_subtitle, + }; + Widget get widget => switch (this) { SettingSection.advanced => const AdvancedSettings(), SettingSection.assetViewer => const AssetViewerSettings(), @@ -46,7 +69,7 @@ enum SettingSection { SettingSection.beta => const SyncStatusAndActions(), }; - const SettingSection(this.title, this.icon, this.subtitle); + const SettingSection(this.icon); } @RoutePage() @@ -55,9 +78,8 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - context.locale; return Scaffold( - appBar: AppBar(centerTitle: false, title: const Text('settings').tr()), + appBar: AppBar(centerTitle: false, title: Text(context.t.settings)), body: context.isMobile ? const _MobileLayout() : const _TabletLayout(), ); } @@ -73,15 +95,15 @@ class _MobileLayout extends StatelessWidget { ? [ SettingsCard( icon: Icons.sync_outlined, - title: 'sync_status'.tr(), - subtitle: 'sync_status_subtitle'.tr(), + title: context.t.sync_status, + subtitle: context.t.sync_status_subtitle, settingRoute: const SyncStatusRoute(), ), ] : [ SettingsCard( - title: setting.title.tr(), - subtitle: setting.subtitle.tr(), + title: setting.title(context.t), + subtitle: setting.subtitle(context.t), icon: setting.icon, settingRoute: SettingsSubRoute(section: setting), ), @@ -116,7 +138,7 @@ class _TabletLayout extends HookWidget { ...SettingSection.values.map( (s) => SliverToBoxAdapter( child: ListTile( - title: Text(s.title).tr(), + title: Text(s.title(context.t)), leading: Icon(s.icon), selected: s.index == selectedSection.value.index, selectedColor: context.primaryColor, @@ -127,7 +149,7 @@ class _TabletLayout extends HookWidget { ), SliverToBoxAdapter( child: ListTile( - title: Text('whats_new'.tr()), + title: Text(context.t.whats_new), leading: const Icon(Icons.auto_awesome_outlined), onTap: () => context.pushRoute(const WhatsNewRoute()), ), @@ -150,9 +172,8 @@ class SettingsSubPage extends StatelessWidget { @override Widget build(BuildContext context) { - context.locale; return Scaffold( - appBar: AppBar(centerTitle: false, title: Text(section.title).tr()), + appBar: AppBar(centerTitle: false, title: Text(section.title(context.t))), body: section.widget, ); } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 2c39883b7e..65a78aed8a 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -353,11 +353,11 @@ class SplashScreenPageState extends ConsumerState { }, onError: (exception) { log.severe('Failed to update auth info with access token: $accessToken'); - unawaited(ref.read(authProvider.notifier).logout()); if (!mounted) { return; } + unawaited(ref.read(authProvider.notifier).logout()); unawaited(context.router.replaceAll([const LoginRoute()])); }, ), diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index f834e4ac51..240c4abe7a 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -1,18 +1,17 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; @@ -34,24 +33,24 @@ class _TabShellPageState extends ConsumerState { final navigationDestinations = [ NavigationDestination( - label: 'photos'.tr(), + label: context.t.photos, icon: const Icon(Icons.photo_library_outlined), selectedIcon: Icon(Icons.photo_library, color: context.primaryColor), ), NavigationDestination( - label: 'search'.tr(), + label: context.t.search, icon: const Icon(Icons.search_rounded), selectedIcon: Icon(Icons.search, color: context.primaryColor), enabled: !isReadonlyModeEnabled, ), NavigationDestination( - label: 'albums'.tr(), + label: context.t.albums, icon: const Icon(Icons.photo_album_outlined), selectedIcon: Icon(Icons.photo_album_rounded, color: context.primaryColor), enabled: !isReadonlyModeEnabled, ), NavigationDestination( - label: 'library'.tr(), + label: context.t.library$, icon: const Icon(Icons.space_dashboard_outlined), selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor), enabled: !isReadonlyModeEnabled, @@ -132,7 +131,6 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { // Library page if (index == kLibraryTabIndex) { ref.invalidate(localAlbumProvider); - ref.invalidate(driftGetAllPeopleProvider); } ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/pages/library/folder/folder.page.dart b/mobile/lib/pages/library/folder/folder.page.dart index 69631efe4e..0264ceee16 100644 --- a/mobile/lib/pages/library/folder/folder.page.dart +++ b/mobile/lib/pages/library/folder/folder.page.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -10,6 +9,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; import 'package:immich_mobile/models/folder/root_folder.model.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; @@ -20,6 +20,7 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:intl/intl.dart'; RecursiveFolder? _findFolderInStructure(RootFolder rootFolder, RecursiveFolder targetFolder) { for (final folder in rootFolder.subfolders) { @@ -81,7 +82,7 @@ class FolderPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: Text(currentFolder.value?.name ?? tr("folders")), + title: Text(currentFolder.value?.name ?? context.t.folders), elevation: 0, centerTitle: false, actions: [IconButton(icon: const Icon(Icons.swap_vert), onPressed: onToggleSortOrder)], @@ -96,8 +97,8 @@ class FolderPage extends HookConsumerWidget { }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) { - ImmichToast.show(context: context, msg: "failed_to_load_folder".tr(), toastType: ToastType.error); - return Center(child: const Text("failed_to_load_folder").tr()); + ImmichToast.show(context: context, msg: context.t.failed_to_load_folder, toastType: ToastType.error); + return Center(child: Text(context.t.failed_to_load_folder)); }, ), ); @@ -125,16 +126,16 @@ class FolderContent extends HookConsumerWidget { }, [folder]); if (folder == null) { - return Center(child: const Text("folder_not_found").tr()); + return Center(child: Text(context.t.folder_not_found)); } String getSubtitle(int subFolderCount) { if (subFolderCount > 0) { - return "$subFolderCount ${tr("folders")}".toLowerCase(); + return "$subFolderCount ${context.t.folders}".toLowerCase(); } if (subFolderCount == 1) { - return "1 ${tr("folder")}".toLowerCase(); + return "1 ${context.t.folder}".toLowerCase(); } return ""; @@ -147,7 +148,7 @@ class FolderContent extends HookConsumerWidget { child: folderRenderlist.when( data: (folderAssets) { if (folder!.subfolders.isEmpty && folderAssets.isEmpty) { - return Center(child: const Text("empty_folder").tr()); + return Center(child: Text(context.t.empty_folder)); } return ListView( @@ -211,8 +212,8 @@ class FolderContent extends HookConsumerWidget { }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) { - ImmichToast.show(context: context, msg: "failed_to_load_assets".tr(), toastType: ToastType.error); - return Center(child: const Text("failed_to_load_assets").tr()); + ImmichToast.show(context: context, msg: context.t.failed_to_load_assets, toastType: ToastType.error); + return Center(child: Text(context.t.failed_to_load_assets)); }, ), ), diff --git a/mobile/lib/pages/library/locked/pin_auth.page.dart b/mobile/lib/pages/library/locked/pin_auth.page.dart index 81e08d853b..db0e009d8c 100644 --- a/mobile/lib/pages/library/locked/pin_auth.page.dart +++ b/mobile/lib/pages/library/locked/pin_auth.page.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' show useState; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/local_auth.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/forms/pin_registration_form.dart'; @@ -31,7 +31,7 @@ class PinAuthPage extends HookConsumerWidget { context.showSnackBar( SnackBar( - content: Text('biometric_auth_enabled'.tr(), style: context.textTheme.labelLarge), + content: Text(context.t.biometric_auth_enabled, style: context.textTheme.labelLarge), duration: const Duration(seconds: 3), backgroundColor: context.colorScheme.primaryContainer, ), @@ -52,7 +52,7 @@ class PinAuthPage extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ PinVerificationForm( - description: 'enable_biometric_auth_description'.tr(), + description: context.t.enable_biometric_auth_description, onSuccess: (pinCode) { Navigator.pop(buildContext); unawaited(registerBiometric(pinCode)); @@ -71,7 +71,7 @@ class PinAuthPage extends HookConsumerWidget { } return Scaffold( - appBar: AppBar(title: Text('locked_folder'.tr())), + appBar: AppBar(title: Text(context.t.locked_folder)), body: ListView( shrinkWrap: true, children: [ @@ -97,7 +97,7 @@ class PinAuthPage extends HookConsumerWidget { icon: const Icon(Icons.fingerprint, size: 28), onPressed: () => unawaited(enableBiometricAuth()), label: Text( - 'use_biometric'.tr(), + context.t.use_biometric, style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor, fontSize: 18), ), ), diff --git a/mobile/lib/pages/library/shared_link/shared_link.page.dart b/mobile/lib/pages/library/shared_link/shared_link.page.dart index 18fc235dd8..70cd4e2129 100644 --- a/mobile/lib/pages/library/shared_link/shared_link.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link.page.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/providers/shared_link.provider.dart'; import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart'; @@ -36,7 +36,7 @@ class SharedLinkPage extends HookConsumerWidget { children: [ Icon(Icons.link_off, size: 100, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)), const SizedBox(height: 20), - const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(), + Text(context.t.you_dont_have_any_shared_links, style: const TextStyle(fontSize: 14)), ], ), ); @@ -68,7 +68,7 @@ class SharedLinkPage extends HookConsumerWidget { } return Scaffold( - appBar: AppBar(title: const Text("shared_link_app_bar_title").tr(), elevation: 0, centerTitle: false), + appBar: AppBar(title: Text(context.t.shared_link_app_bar_title), elevation: 0, centerTitle: false), body: SafeArea( child: sharedLinks.widgetWhen( onError: (error, stackTrace) => buildNoShares(), diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart index 23c24a64b0..6c90571428 100644 --- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart @@ -427,8 +427,8 @@ class SharedLinkEditPage extends HookConsumerWidget { return showDialog( context: context, builder: (BuildContext context) => ConfirmDialog( - title: "delete_shared_link_dialog_title", - content: "confirm_delete_shared_link", + title: context.t.delete_shared_link_dialog_title, + content: context.t.confirm_delete_shared_link, onOk: () async { await ref.read(sharedLinkServiceProvider).deleteSharedLink(existingLink!.id); ref.invalidate(sharedLinksStateProvider); diff --git a/mobile/lib/pages/login/change_password.page.dart b/mobile/lib/pages/login/change_password.page.dart index 248526df1b..051f7f408c 100644 --- a/mobile/lib/pages/login/change_password.page.dart +++ b/mobile/lib/pages/login/change_password.page.dart @@ -1,14 +1,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/widgets/forms/change_password_form.dart'; @RoutePage() -class ChangePasswordPage extends HookConsumerWidget { +class ChangePasswordPage extends StatelessWidget { const ChangePasswordPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - return const Scaffold(body: ChangePasswordForm()); - } + Widget build(BuildContext context) => const Scaffold(body: ChangePasswordForm()); } diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart index e225c4c066..228fa1cc30 100644 --- a/mobile/lib/pages/login/login.page.dart +++ b/mobile/lib/pages/login/login.page.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -11,11 +10,11 @@ import 'package:immich_mobile/widgets/forms/login/login_form.dart'; import 'package:package_info_plus/package_info_plus.dart'; @RoutePage() -class LoginPage extends HookConsumerWidget { +class LoginPage extends HookWidget { const LoginPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final appVersion = useState('0.0.0'); Future getAppInfo() async { diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart index bb848b24bc..314f2aba7d 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -2,25 +2,24 @@ import 'dart:async'; import 'dart:math'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() -class MapLocationPickerPage extends HookConsumerWidget { +class MapLocationPickerPage extends HookWidget { final LatLng initialLatLng; const MapLocationPickerPage({super.key, this.initialLatLng = const LatLng(0, 0)}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final selectedLatLng = useValueNotifier(initialLatLng); final controller = useRef(null); final marker = useRef(null); @@ -141,7 +140,7 @@ class _BottomBar extends StatelessWidget { const SizedBox(width: 15), ValueListenableBuilder( valueListenable: selectedLatLng, - builder: (_, value, __) => + builder: (_, value, _) => Text("${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}"), ), ], @@ -149,10 +148,7 @@ class _BottomBar extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - ElevatedButton( - onPressed: onUseLocation, - child: const Text("map_location_picker_page_use_location").tr(), - ), + ElevatedButton(onPressed: onUseLocation, child: Text(context.t.map_location_picker_page_use_location)), ElevatedButton(onPressed: onGetCurrentLocation, child: const Icon(Icons.my_location)), ], ), diff --git a/mobile/lib/pages/settings/sync_status.page.dart b/mobile/lib/pages/settings/sync_status.page.dart index 58750e9e30..6a0af1230d 100644 --- a/mobile/lib/pages/settings/sync_status.page.dart +++ b/mobile/lib/pages/settings/sync_status.page.dart @@ -1,6 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart'; @RoutePage() @@ -12,7 +12,7 @@ class SyncStatusPage extends StatelessWidget { return Scaffold( appBar: AppBar( elevation: 0, - title: const Text("sync_status").t(context: context), + title: Text(context.t.sync_status), leading: IconButton( onPressed: () => context.maybePop(true), splashRadius: 24, diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart index db5e583d7a..5b651fbc8f 100644 --- a/mobile/lib/pages/share_intent/share_intent.page.dart +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; @@ -58,7 +58,7 @@ class ShareIntentPage extends ConsumerWidget { appBar: AppBar( title: Column( children: [ - const Text('upload_to_immich').tr(namedArgs: {'count': candidates.length.toString()}), + Text(context.t.upload_to_immich(count: candidates.length)), Text( currentEndpoint, style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)), @@ -130,7 +130,7 @@ class ShareIntentPage extends ConsumerWidget { height: 48, child: ElevatedButton( onPressed: (isUploading || isUploaded) ? null : upload, - child: (isUploading || isUploaded) ? UploadingText(candidates: candidates) : const Text('upload').tr(), + child: (isUploading || isUploaded) ? UploadingText(candidates: candidates) : Text(context.t.upload), ), ), ), @@ -149,9 +149,7 @@ class UploadingText extends StatelessWidget { return element.status == UploadStatus.complete; }).length; - return const Text( - "shared_intent_upload_button_progress_text", - ).tr(namedArgs: {'current': uploadedCount.toString(), 'total': candidates.length.toString()}); + return Text(context.t.shared_intent_upload_button_progress_text(current: uploadedCount, total: candidates.length)); } } @@ -168,7 +166,7 @@ class UploadStatusIcon extends StatelessWidget { return Icon( Icons.check_circle_outline_rounded, color: context.colorScheme.onSurface.withAlpha(100), - semanticLabel: 'not_selected'.tr(), + semanticLabel: context.t.not_selected, ); } @@ -176,7 +174,7 @@ class UploadStatusIcon extends StatelessWidget { UploadStatus.enqueued => Icon( Icons.check_circle_rounded, color: context.primaryColor, - semanticLabel: 'enqueued'.tr(), + semanticLabel: context.t.enqueued, ), UploadStatus.running => Stack( alignment: AlignmentDirectional.center, @@ -191,7 +189,7 @@ class UploadStatusIcon extends StatelessWidget { backgroundColor: context.colorScheme.surfaceContainerLow, strokeWidth: 3, value: value, - semanticsLabel: 'uploading'.tr(), + semanticsLabel: context.t.uploading, ), ), ), @@ -201,8 +199,12 @@ class UploadStatusIcon extends StatelessWidget { ), ], ), - UploadStatus.complete => Icon(Icons.check_circle_rounded, color: Colors.green, semanticLabel: 'completed'.tr()), - UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: 'failed'.tr()), + UploadStatus.complete => Icon( + Icons.check_circle_rounded, + color: Colors.green, + semanticLabel: context.t.completed, + ), + UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: context.t.failed), }; return statusIcon; diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index 31d01b32ef..23a2ef2a2f 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -37,7 +37,8 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); // Assets already in the trash or in the locked folder are deleted outright, irrespective of the server setting. - final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked); + final trash = + ownedRemote.isEmpty || (trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked)); return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash); }, dependencies: [assetsActionProvider]); @@ -127,8 +128,11 @@ class DeleteAction extends AssetActionBuilder { final assetService = ref.read(assetServiceProvider); final confirmed = await showDialog( context: context, - builder: (_) => - const ConfirmDialog(title: 'delete_dialog_title', content: 'delete_dialog_alert', ok: 'delete_permanently'), + builder: (_) => ConfirmDialog( + title: context.t.delete_dialog_title, + content: context.t.delete_dialog_alert, + ok: context.t.delete_permanently, + ), ); if (confirmed != true || !context.mounted) { return null; diff --git a/mobile/lib/presentation/actions/edit_asset.action.dart b/mobile/lib/presentation/actions/edit_asset.action.dart index 9b11032df9..e2b4993c3b 100644 --- a/mobile/lib/presentation/actions/edit_asset.action.dart +++ b/mobile/lib/presentation/actions/edit_asset.action.dart @@ -7,21 +7,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/error_handler.dart'; -import 'package:immich_mobile/utils/semver.dart'; - -const _minimumServerVersion = SemVer(major: 2, minor: 6, patch: 0); final _stateProvider = Provider.family.autoDispose((ref, source) { - final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion >= _minimumServerVersion)); + final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion.supports(.assetEdits))); if (!isSupported) { return null; } @@ -50,7 +49,7 @@ class EditAssetAction extends AssetActionBuilder { try { // TODO(shenlong): Move all EXIF and Apply Edits logic onto the Route - final repository = ref.read(remoteAssetRepositoryProvider); + final repository = ref.read(driftProvider).remoteAssetRepository; final (edits, exif) = await (repository.getAssetEdits(asset.id), repository.getExif(asset.id)).wait; if (exif == null || !context.mounted) { return; diff --git a/mobile/lib/presentation/actions/edit_datetime.action.dart b/mobile/lib/presentation/actions/edit_datetime.action.dart index 31de23d1a7..4ca3ddbaf4 100644 --- a/mobile/lib/presentation/actions/edit_datetime.action.dart +++ b/mobile/lib/presentation/actions/edit_datetime.action.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_mobile/utils/timezone.dart'; @@ -46,7 +47,7 @@ class EditDateTimeAction extends AssetActionBuilder { } final (:assetIds, :origin) = state; - final remoteAssetRepository = ref.read(remoteAssetRepositoryProvider); + final remoteAssetRepository = ref.read(driftProvider).remoteAssetRepository; final clearSelection = ref.read(clearSelectionProvider(source)); try { diff --git a/mobile/lib/presentation/actions/edit_location.action.dart b/mobile/lib/presentation/actions/edit_location.action.dart index 5ce74a0f40..01318de999 100644 --- a/mobile/lib/presentation/actions/edit_location.action.dart +++ b/mobile/lib/presentation/actions/edit_location.action.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; @@ -46,7 +47,7 @@ class EditLocationAction extends AssetActionBuilder { } final (:assetIds, :origin) = state; - final remoteAssetRepository = ref.read(remoteAssetRepositoryProvider); + final remoteAssetRepository = ref.read(driftProvider).remoteAssetRepository; final clearSelection = ref.read(clearSelectionProvider(source)); try { diff --git a/mobile/lib/presentation/actions/similar_photos.action.dart b/mobile/lib/presentation/actions/similar_photos.action.dart index 3c0b7b129b..d8f63f245d 100644 --- a/mobile/lib/presentation/actions/similar_photos.action.dart +++ b/mobile/lib/presentation/actions/similar_photos.action.dart @@ -31,7 +31,7 @@ class SimilarPhotosAction extends ActionBuilder { location: .new(), camera: .new(), date: .new(), - display: .new(isNotInAlbum: false, isArchive: false, isFavorite: false), + display: const .new(isNotInAlbum: false, isArchive: false, isFavorite: false), rating: .new(), mediaType: .other, ), diff --git a/mobile/lib/presentation/pages/cleanup_preview.page.dart b/mobile/lib/presentation/pages/cleanup_preview.page.dart index 556ed6412f..45f8c80c91 100644 --- a/mobile/lib/presentation/pages/cleanup_preview.page.dart +++ b/mobile/lib/presentation/pages/cleanup_preview.page.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -19,7 +19,7 @@ class CleanupPreviewPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text('cleanup_preview_title'.t(context: context, args: {'count': assets.length.toString()})), + title: Text(context.t.cleanup_preview_title(count: assets.length)), centerTitle: true, elevation: 0, scrolledUnderElevation: 0, diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index 4c18a09200..ec6a260c84 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -1,11 +1,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -56,11 +55,11 @@ class LocalMediaSummaryPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text('local_media_summary'.tr())), + appBar: AppBar(title: Text(context.t.local_media_summary)), body: Consumer( - builder: (ctx, ref, __) { + builder: (ctx, ref, _) { final db = ref.watch(driftProvider); - final albumsFuture = ref.watch(localAlbumRepository).getAll(); + final albumsFuture = db.localAlbumRepository.getAll(); return CustomScrollView( slivers: [ @@ -79,7 +78,7 @@ class LocalMediaSummaryPage extends StatelessWidget { const Divider(), Padding( padding: const EdgeInsets.only(left: 15), - child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium), + child: Text(ctx.t.album_summary, style: ctx.textTheme.titleMedium), ), ], ), @@ -136,11 +135,11 @@ class RemoteMediaSummaryPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text('remote_media_summary'.tr())), + appBar: AppBar(title: Text(context.t.remote_media_summary)), body: Consumer( - builder: (ctx, ref, __) { + builder: (ctx, ref, _) { final db = ref.watch(driftProvider); - final albumsFuture = ref.watch(remoteAlbumRepository).getAll(); + final albumsFuture = db.remoteAlbumRepository.getAll(); return CustomScrollView( slivers: [ @@ -159,7 +158,7 @@ class RemoteMediaSummaryPage extends StatelessWidget { const Divider(), Padding( padding: const EdgeInsets.only(left: 15), - child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium), + child: Text(ctx.t.album_summary, style: ctx.textTheme.titleMedium), ), ], ), diff --git a/mobile/lib/presentation/pages/download_info.page.dart b/mobile/lib/presentation/pages/download_info.page.dart index c2c63c7860..09a65b4586 100644 --- a/mobile/lib/presentation/pages/download_info.page.dart +++ b/mobile/lib/presentation/pages/download_info.page.dart @@ -4,7 +4,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; @@ -17,14 +17,11 @@ class DownloadInfoPage extends ConsumerWidget { final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList(); void onCancelDownload(String id) { - unawaited(ref.watch(downloadStateProvider.notifier).cancelDownload(id)); + unawaited(ref.read(downloadStateProvider.notifier).cancelDownload(id)); } return Scaffold( - appBar: AppBar( - title: Text("download".t(context: context)), - actions: const [], - ), + appBar: AppBar(title: Text(context.t.download), actions: const []), body: ListView.builder( physics: const ClampingScrollPhysics(), shrinkWrap: true, @@ -49,7 +46,7 @@ class DownloadInfoPage extends ConsumerWidget { }, style: OutlinedButton.styleFrom(side: BorderSide(color: context.colorScheme.primary)), child: Text( - 'clear_all'.t(context: context), + context.t.clear_all, style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary), ), ), diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index ebf7c2efa7..2bb3507e6e 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -23,7 +23,7 @@ class DriftActivitiesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final activityNotifier = ref.read(albumActivityProvider((album.id, assetId)).notifier); + final activityNotifier = ref.watch(albumActivityProvider((album.id, assetId)).notifier); final activities = ref.watch(albumActivityProvider((album.id, assetId))); final listViewScrollController = useScrollController(); diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 9c5161fa8a..078b9cc325 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; @@ -32,12 +32,14 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.isActivityEnabled); final isOwner = album.ownerId == userId; + final owner = isOwner ? ref.watch(currentUserProvider) : null; + final allUsers = isOwner ? null : ref.watch(driftUsersProvider); void showErrorMessage() { ContextHelper(context).pop(); ImmichToast.show( context: context, - msg: "shared_album_section_people_action_error".t(context: context), + msg: context.t.shared_album_section_people_action_error, toastType: ToastType.error, gravity: ToastGravity.BOTTOM, ); @@ -75,6 +77,10 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { } try { + if (!context.mounted) { + return; + } + await ref.read(remoteAlbumProvider.notifier).addUsers(album.id, newUsers); ref.invalidate(remoteAlbumSharedUsersProvider(album.id)); if (!context.mounted) { @@ -83,7 +89,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { ImmichToast.show( context: context, - msg: "users_added_to_album_count".t(context: context, args: {'count': newUsers.length}), + msg: context.t.users_added_to_album_count(count: newUsers.length), toastType: ToastType.success, ); } catch (e) { @@ -102,7 +108,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { actions = [ ListTile( leading: const Icon(Icons.exit_to_app_rounded), - title: const Text("leave_album").t(context: context), + title: Text(context.t.leave_album), onTap: leaveAlbum, ), ]; @@ -112,7 +118,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { actions = [ ListTile( leading: const Icon(Icons.person_remove_rounded), - title: const Text("remove_user").t(context: context), + title: Text(context.t.remove_user), onTap: () => removeUserFromAlbum(user), ), ]; @@ -137,16 +143,18 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { Widget buildOwnerInfo() { if (isOwner) { - final owner = ref.watch(currentUserProvider); return ListTile( leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(), title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(owner?.email ?? "", style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), - trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context), + trailing: Text(context.t.owner, style: context.textTheme.labelLarge), ); } else { - final usersProvider = ref.watch(driftUsersProvider); - return usersProvider.maybeWhen( + if (allUsers == null) { + return const SizedBox(); + } + + return allUsers.maybeWhen( data: (users) { final user = users.firstWhereOrNull((u) => u.id == album.ownerId); @@ -158,7 +166,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), - trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context), + trailing: Text(context.t.owner, style: context.textTheme.labelLarge), ); }, orElse: () => const SizedBox(), @@ -203,7 +211,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { onPressed: () => context.maybePop(null), ), centerTitle: true, - title: Text("options".t(context: context)), + title: Text(context.t.options), ), body: ListView( children: [ @@ -218,19 +226,19 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor, dense: true, title: Text( - "comments_and_likes", + context.t.comments_and_likes, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), - ).t(context: context), + ), subtitle: Text( - "let_others_respond", + context.t.let_others_respond, style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ).t(context: context), + ), ), - buildSectionTitle("shared_album_section_people_title".t(context: context)), + buildSectionTitle(context.t.shared_album_section_people_title), if (isOwner) ...[ ListTile( leading: const Icon(Icons.person_add_rounded), - title: Text("invite_people".t(context: context)), + title: Text(context.t.invite_people), onTap: () async => addUsers(), ), const Divider(indent: 16), diff --git a/mobile/lib/presentation/pages/drift_archive.page.dart b/mobile/lib/presentation/pages/drift_archive.page.dart index ee8417bcad..75c05eb070 100644 --- a/mobile/lib/presentation/pages/drift_archive.page.dart +++ b/mobile/lib/presentation/pages/drift_archive.page.dart @@ -1,7 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -28,10 +28,7 @@ class DriftArchivePage extends StatelessWidget { }), ], child: Timeline( - appBar: MesmerizingSliverAppBar( - title: 'archive'.t(context: context), - icon: Icons.archive_outlined, - ), + appBar: MesmerizingSliverAppBar(title: context.t.archive, icon: Icons.archive_outlined), bottomSheet: const ArchiveBottomSheet(), ), ); diff --git a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart index a12ca4932b..d256cc2220 100644 --- a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart @@ -7,12 +7,12 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @RoutePage() -class DriftAssetSelectionTimelinePage extends ConsumerWidget { +class DriftAssetSelectionTimelinePage extends StatelessWidget { final Set lockedSelectionAssets; const DriftAssetSelectionTimelinePage({super.key, this.lockedSelectionAssets = const {}}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return ProviderScope( overrides: [ multiSelectProvider.overrideWith( diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart index d50e4b190e..81d765239b 100644 --- a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart @@ -1,24 +1,24 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; @RoutePage() -class AssetTroubleshootPage extends ConsumerWidget { +class AssetTroubleshootPage extends StatelessWidget { final BaseAsset asset; const AssetTroubleshootPage({super.key, required this.asset}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text('asset_troubleshoot'.tr())), + appBar: AppBar(title: Text(context.t.asset_troubleshoot)), body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16.0), @@ -29,20 +29,20 @@ class AssetTroubleshootPage extends ConsumerWidget { } } -class _AssetDetailsView extends ConsumerWidget { +class _AssetDetailsView extends StatelessWidget { final BaseAsset asset; const _AssetDetailsView({required this.asset}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _AssetPropertiesSection(asset: asset), const SizedBox(height: 16), Text( - 'matching_assets'.tr(), + context.t.matching_assets, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), if (asset.checksum != null) ...[ @@ -52,12 +52,12 @@ class _AssetDetailsView extends ConsumerWidget { ] else ...[ _PropertySectionCard( title: 'Local Assets', - properties: [_PropertyItem(label: 'Status', value: 'no_checksum_local'.tr())], + properties: [_PropertyItem(label: 'Status', value: context.t.no_checksum_local)], ), const SizedBox(height: 16), _PropertySectionCard( title: 'Remote Assets', - properties: [_PropertyItem(label: 'Status', value: 'no_checksum_remote'.tr())], + properties: [_PropertyItem(label: 'Status', value: context.t.no_checksum_remote)], ), ], ], @@ -243,7 +243,7 @@ class _LocalAssetsSection extends ConsumerWidget { if (localAssets.isEmpty) { return _PropertySectionCard( title: 'Local Assets', - properties: [_PropertyItem(label: 'Status', value: 'no_local_assets_found'.tr())], + properties: [_PropertyItem(label: 'Status', value: context.t.no_local_assets_found)], ); } @@ -302,7 +302,7 @@ class _RemoteAssetSection extends ConsumerWidget { if (remoteAsset == null) { return _PropertySectionCard( title: 'Remote Assets', - properties: [_PropertyItem(label: 'Status', value: 'no_remote_assets_found'.tr())], + properties: [_PropertyItem(label: 'Status', value: context.t.no_remote_assets_found)], ); } @@ -356,7 +356,7 @@ class _PropertyItem extends StatelessWidget { ), Expanded( child: Text( - value ?? 'not_available'.tr(), + value ?? context.t.not_available, style: TextStyle(color: Theme.of(context).colorScheme.secondary), ), ), diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index 93534d62a5..f536b5b60f 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -52,9 +52,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty && !isCreatingAlbum; String _getEffectiveTitle() { - return albumTitleController.text.isNotEmpty - ? albumTitleController.text - : 'create_album_page_untitled'.t(context: context); + return albumTitleController.text.isNotEmpty ? albumTitleController.text : context.t.create_album_page_untitled; } Widget _buildSliverAppBar() { @@ -93,7 +91,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { Widget _buildEmptyState() { return Padding( padding: const EdgeInsets.only(top: 0, left: 18), - child: Text('create_shared_album_page_share_add_assets', style: context.textTheme.labelLarge).t(), + child: Text(context.t.create_shared_album_page_share_add_assets, style: context.textTheme.labelLarge), ); } @@ -112,9 +110,9 @@ class _DriftCreateAlbumPageState extends ConsumerState { label: Padding( padding: const EdgeInsets.only(left: 8.0), child: Text( - 'create_shared_album_page_share_select_photos', + context.t.create_shared_album_page_share_select_photos, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor), - ).t(), + ), ), ), ); @@ -148,7 +146,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { }); if (albumTitleController.text.isEmpty) { - final untitledText = 'create_album_page_untitled'.t(); + final untitledText = context.t.create_album_page_untitled; albumTitleController.text = untitledText; } } @@ -196,7 +194,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { return; } - ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t()); + ImmichToast.show(context: context, toastType: ToastType.error, msg: context.t.errors.failed_to_create_album); } finally { if (mounted) { setState(() => isCreatingAlbum = false); @@ -241,7 +239,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { AlbumActionFilledButton( iconData: Icons.add_photo_alternate_outlined, onPressed: onSelectPhotos, - labelText: "add_photos".t(), + labelText: context.t.add_photos, ), ], ), @@ -257,12 +255,12 @@ class _DriftCreateAlbumPageState extends ConsumerState { centerTitle: false, backgroundColor: context.scaffoldBackgroundColor, leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.close_rounded)), - title: const Text('create_album').t(), + title: Text(context.t.create_album), actions: [ TextButton( onPressed: _canCreateAlbum ? createAlbum : null, child: Text( - 'create'.t(), + context.t.create, style: TextStyle( fontWeight: FontWeight.bold, color: _canCreateAlbum ? context.primaryColor : context.themeData.disabledColor, @@ -320,7 +318,7 @@ class _AlbumTitleTextFieldState extends State<_AlbumTitleTextField> { style: TextStyle(fontSize: 28.0, color: context.colorScheme.onSurface, fontWeight: FontWeight.bold), controller: widget.textController, onTap: () { - if (widget.textController.text == 'create_album_page_untitled'.t(context: context)) { + if (widget.textController.text == context.t.create_album_page_untitled) { widget.textController.clear(); } }, @@ -343,7 +341,7 @@ class _AlbumTitleTextFieldState extends State<_AlbumTitleTextField> { borderSide: BorderSide(color: context.primaryColor.withValues(alpha: 0.3)), borderRadius: const BorderRadius.all(Radius.circular(16.0)), ), - hintText: 'add_a_title'.t(), + hintText: context.t.add_a_title, hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith( fontSize: 28.0, fontWeight: FontWeight.bold, @@ -430,7 +428,7 @@ class _AlbumViewerEditableDescriptionState extends State<_AlbumViewerEditableDes focusColor: Colors.grey[300], fillColor: context.scaffoldBackgroundColor, filled: widget.focusNode.hasFocus, - hintText: 'add_a_description'.t(), + hintText: context.t.add_a_description, ), ), ); diff --git a/mobile/lib/presentation/pages/drift_favorite.page.dart b/mobile/lib/presentation/pages/drift_favorite.page.dart index 2bc3f26363..dd4384906b 100644 --- a/mobile/lib/presentation/pages/drift_favorite.page.dart +++ b/mobile/lib/presentation/pages/drift_favorite.page.dart @@ -1,7 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -28,10 +28,7 @@ class DriftFavoritePage extends StatelessWidget { }), ], child: Timeline( - appBar: MesmerizingSliverAppBar( - title: 'favorites'.t(context: context), - icon: Icons.favorite_outline, - ), + appBar: MesmerizingSliverAppBar(title: context.t.favorites, icon: Icons.favorite_outline), bottomSheet: const FavoriteBottomSheet(), ), ); diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index b2b4d250f1..137f46659f 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -1,11 +1,10 @@ import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart'; @@ -21,11 +20,11 @@ import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() -class DriftLibraryPage extends ConsumerWidget { +class DriftLibraryPage extends StatelessWidget { const DriftLibraryPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return const Scaffold( body: CustomScrollView( slivers: [ @@ -56,13 +55,13 @@ class _ActionButtonGrid extends ConsumerWidget { _ActionButton( icon: Icons.favorite_outline_rounded, onTap: () => context.pushRoute(const DriftFavoriteRoute()), - label: 'favorites'.t(context: context), + label: context.t.favorites, ), const SizedBox(width: 8), _ActionButton( icon: Icons.archive_outlined, onTap: () => context.pushRoute(const DriftArchiveRoute()), - label: 'archived'.t(context: context), + label: context.t.archived, ), ], ), @@ -72,14 +71,14 @@ class _ActionButtonGrid extends ConsumerWidget { _ActionButton( icon: Icons.link_outlined, onTap: () => context.pushRoute(const SharedLinkRoute()), - label: 'shared_links'.t(context: context), + label: context.t.shared_links, ), isTrashEnable ? const SizedBox(width: 8) : const SizedBox.shrink(), isTrashEnable ? _ActionButton( icon: Icons.delete_outline_rounded, onTap: () => context.pushRoute(const DriftTrashRoute()), - label: 'trash'.t(context: context), + label: context.t.trash, ) : const SizedBox.shrink(), ], @@ -146,7 +145,7 @@ class _PeopleCollectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final people = ref.watch(driftGetAllPeopleProvider); + final people = ref.watch(getAllPeopleProvider); return LayoutBuilder( builder: (context, constraints) { @@ -193,7 +192,7 @@ class _PeopleCollectionCard extends ConsumerWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - 'people'.t(context: context), + context.t.people, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -245,7 +244,7 @@ class _PlacesCollectionCard extends StatelessWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - 'places'.t(), + context.t.places, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -303,9 +302,7 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget { }).toList(); }, error: (error, _) { - return [ - Center(child: Text('error_saving_image'.tr(args: [error.toString()]))), - ]; + return [Center(child: Text(context.t.error_saving_image(error: error.toString())))]; }, loading: () { return [const Center(child: CircularProgressIndicator())]; @@ -317,7 +314,7 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - 'on_this_device'.t(context: context), + context.t.on_this_device, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -384,7 +381,7 @@ class _QuickAccessButtonList extends ConsumerWidget { ), leading: const Icon(Icons.folder_outlined, size: 26), title: Text( - 'folders'.t(context: context), + context.t.folders, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), ), onTap: () => context.pushRoute(FolderRoute()), @@ -392,7 +389,7 @@ class _QuickAccessButtonList extends ConsumerWidget { ListTile( leading: const Icon(Icons.lock_outline_rounded, size: 26), title: Text( - 'locked_folder'.t(context: context), + context.t.locked_folder, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), ), onTap: () => context.pushRoute(const DriftLockedFolderRoute()), @@ -400,7 +397,7 @@ class _QuickAccessButtonList extends ConsumerWidget { ListTile( leading: const Icon(Icons.group_outlined, size: 26), title: Text( - 'partners'.t(context: context), + context.t.partners, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), ), onTap: () => context.pushRoute(const PartnerRoute()), @@ -438,10 +435,10 @@ class _PartnerList extends StatelessWidget { ), contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0), leading: PartnerUserAvatar(userId: partner.id, name: partner.name), - title: const Text( - "partner_list_user_photos", - style: TextStyle(fontWeight: FontWeight.w500), - ).t(context: context, args: {'user': partner.name}), + title: Text( + context.t.partner_list_user_photos(user: partner.name), + style: const TextStyle(fontWeight: FontWeight.w500), + ), onTap: () => context.pushRoute(DriftPartnerDetailRoute(partner: partner)), ); }, diff --git a/mobile/lib/presentation/pages/drift_local_album.page.dart b/mobile/lib/presentation/pages/drift_local_album.page.dart index 6b133f7a6f..71707688da 100644 --- a/mobile/lib/presentation/pages/drift_local_album.page.dart +++ b/mobile/lib/presentation/pages/drift_local_album.page.dart @@ -1,10 +1,9 @@ import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; @@ -49,7 +48,7 @@ class _AlbumList extends ConsumerWidget { if (albums.isEmpty) { return SliverToBoxAdapter( child: Center( - child: Padding(padding: const EdgeInsets.all(20.0), child: Text('no_albums_yet'.tr())), + child: Padding(padding: const EdgeInsets.all(20.0), child: Text(context.t.no_albums_yet)), ), ); } @@ -67,7 +66,7 @@ class _AlbumList extends ConsumerWidget { leading: SizedBox(width: 80, height: 80, child: LocalAlbumThumbnail(albumId: album.id)), title: Text(album.name, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), subtitle: Text( - 'items_count'.t(context: context, args: {'count': album.assetCount}), + context.t.items_count(count: album.assetCount), style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), onTap: () => context.pushRoute(LocalTimelineRoute(album: album)), diff --git a/mobile/lib/presentation/pages/drift_locked_folder.page.dart b/mobile/lib/presentation/pages/drift_locked_folder.page.dart index e8130f7059..6ebe7283d3 100644 --- a/mobile/lib/presentation/pages/drift_locked_folder.page.dart +++ b/mobile/lib/presentation/pages/drift_locked_folder.page.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; @@ -70,7 +70,7 @@ class _DriftLockedFolderPageState extends ConsumerState w : PopScope( onPopInvokedWithResult: (didPop, _) => didPop ? ref.read(authProvider.notifier).lockPinCode() : null, child: Timeline( - appBar: MesmerizingSliverAppBar(title: 'locked_folder'.t(context: context)), + appBar: MesmerizingSliverAppBar(title: context.t.locked_folder), bottomSheet: const LockedFolderBottomSheet(), ), ), diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart index 31371fe581..3ad7d90657 100644 --- a/mobile/lib/presentation/pages/drift_memory.page.dart +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -8,7 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; @@ -248,7 +248,7 @@ class DriftMemoryPage extends HookConsumerWidget { } final yearsAgo = DateTime.now().year - memories[mIndex].data.year; - final title = 'years_ago'.t(context: context, args: {'years': yearsAgo.toString()}); + final title = context.t.years_ago(years: yearsAgo); // Build horizontal page final assetController = memoryAssetPageControllers[mIndex]; return Column( diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart index 416b7d587a..92f1ff79ca 100644 --- a/mobile/lib/presentation/pages/drift_people_collection.page.dart +++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -33,7 +34,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState _formFocus.unfocus(), onChanged: (value) => setState(() => _search = value), filled: true, - hintText: 'filter_people'.tr(), + hintText: context.t.filter_people, autofocus: true, ) - : Text('people'.tr()), + : Text(context.t.people), actions: [ IconButton( icon: Icon(_search != null ? Icons.close : Icons.search), @@ -65,12 +66,15 @@ class _DriftPeopleCollectionPageState extends ConsumerState filtered; if (_search != null) { - people = people.where((person) { + filtered = people.where((person) { return person.name.toLowerCase().removeDiacritics().contains( _search!.toLowerCase().removeDiacritics(), ); }).toList(); + } else { + filtered = people; } return GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -79,9 +83,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState showNameEditModal(context, person), child: person.name.isEmpty ? Text( - 'add_a_name'.tr(), + context.t.add_a_name, style: context.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w500, color: context.colorScheme.primary, diff --git a/mobile/lib/presentation/pages/drift_person.page.dart b/mobile/lib/presentation/pages/drift_person.page.dart index 7389f506d5..cad3b7b6a6 100644 --- a/mobile/lib/presentation/pages/drift_person.page.dart +++ b/mobile/lib/presentation/pages/drift_person.page.dart @@ -14,7 +14,7 @@ import 'package:immich_mobile/widgets/common/person_sliver_app_bar.dart'; @RoutePage() class DriftPersonPage extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonPage({super.key, required this.person}); @@ -23,7 +23,7 @@ class DriftPersonPage extends ConsumerStatefulWidget { } class _DriftPersonPageState extends ConsumerState { - late DriftPerson _person; + late Person _person; @override void initState() { diff --git a/mobile/lib/presentation/pages/drift_place.page.dart b/mobile/lib/presentation/pages/drift_place.page.dart index 10b9ca7ae4..0d33f2ab29 100644 --- a/mobile/lib/presentation/pages/drift_place.page.dart +++ b/mobile/lib/presentation/pages/drift_place.page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; @@ -62,10 +62,10 @@ class _PlaceSliverAppBar extends HookWidget { onTapOutside: (_) => searchFocusNode.unfocus(), onChanged: (value) => search.value = value, filled: true, - hintText: 'filter_places'.t(context: context), + hintText: context.t.filter_places, autofocus: true, ) - : Text('places'.t(context: context)), + : Text(context.t.places), actions: [ IconButton( icon: Icon(search.value != null ? Icons.close : Icons.search), @@ -94,7 +94,7 @@ class _Map extends StatelessWidget { height: 200, width: context.width, child: MapThumbnail( - onTap: (_, __) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)), + onTap: (_, _) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)), zoom: 8, centre: currentLocation ?? const LatLng(21.44950, -157.91959), showAttribution: false, @@ -134,16 +134,19 @@ class _PlaceList extends ConsumerWidget { ), ), data: (places) { + final List<(String, String)> filtered; if (search.value != null) { - places = places.where((place) { + filtered = places.where((place) { return place.$1.toLowerCase().contains(search.value!.toLowerCase()); }).toList(); + } else { + filtered = places; } return SliverList.builder( - itemCount: places.length, + itemCount: filtered.length, itemBuilder: (context, index) { - final place = places[index]; + final place = filtered[index]; return _PlaceTile(place: place); }, ); diff --git a/mobile/lib/presentation/pages/drift_recently_added.page.dart b/mobile/lib/presentation/pages/drift_recently_added.page.dart index c33cd4370d..841fb9a671 100644 --- a/mobile/lib/presentation/pages/drift_recently_added.page.dart +++ b/mobile/lib/presentation/pages/drift_recently_added.page.dart @@ -1,7 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -26,7 +26,7 @@ class DriftRecentlyAddedPage extends StatelessWidget { return timelineService; }), ], - child: Timeline(appBar: MesmerizingSliverAppBar(title: 'recently_added'.t())), + child: Timeline(appBar: MesmerizingSliverAppBar(title: context.t.recently_added)), ); } } diff --git a/mobile/lib/presentation/pages/drift_recently_taken.page.dart b/mobile/lib/presentation/pages/drift_recently_taken.page.dart index ceb121b124..22d89770fe 100644 --- a/mobile/lib/presentation/pages/drift_recently_taken.page.dart +++ b/mobile/lib/presentation/pages/drift_recently_taken.page.dart @@ -1,7 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -26,7 +26,7 @@ class DriftRecentlyTakenPage extends StatelessWidget { return timelineService; }), ], - child: Timeline(appBar: MesmerizingSliverAppBar(title: 'recently_taken'.t())), + child: Timeline(appBar: MesmerizingSliverAppBar(title: context.t.recently_taken)), ); } } diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index 5d018561cc..7669b6c765 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/album/pending_uploads_banner.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart'; @@ -62,7 +62,7 @@ class _RemoteAlbumPageState extends ConsumerState { if (added > 0) { ImmichToast.show( context: context, - msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}), + msg: context.t.assets_added_to_album_count(count: added), toastType: ToastType.success, ); } @@ -76,6 +76,10 @@ class _RemoteAlbumPageState extends ConsumerState { } try { + if (!context.mounted) { + return; + } + await ref.read(remoteAlbumProvider.notifier).addUsers(_album.id, newUsers); ref.invalidate(remoteAlbumSharedUsersProvider(_album.id)); if (!context.mounted) { @@ -84,7 +88,7 @@ class _RemoteAlbumPageState extends ConsumerState { ImmichToast.show( context: context, - msg: "users_added_to_album_count".t(context: context, args: {'count': newUsers.length}), + msg: context.t.users_added_to_album_count(count: newUsers.length), toastType: ToastType.success, ); } catch (e) { @@ -107,24 +111,21 @@ class _RemoteAlbumPageState extends ConsumerState { context: context, builder: (BuildContext context) { return AlertDialog( - title: Text('delete_album'.t(context: context)), + title: Text(context.t.delete_album), content: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('album_delete_confirmation'.t(context: context, args: {'album': _album.name})), + Text(context.t.album_delete_confirmation(album: _album.name)), const SizedBox(height: 8), - Text('album_delete_confirmation_description'.t(context: context)), + Text(context.t.album_delete_confirmation_description), ], ), actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text('cancel'.t(context: context)), - ), + TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(context.t.cancel)), TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error), - child: Text('delete_album'.t(context: context)), + child: Text(context.t.delete_album), ), ], ); @@ -133,16 +134,16 @@ class _RemoteAlbumPageState extends ConsumerState { if (confirmed == true) { try { + if (!context.mounted) { + return; + } + await ref.read(remoteAlbumProvider.notifier).deleteAlbum(_album.id); if (!context.mounted) { return; } - ImmichToast.show( - context: context, - msg: 'album_deleted'.t(context: context), - toastType: ToastType.success, - ); + ImmichToast.show(context: context, msg: context.t.album_deleted, toastType: ToastType.success); unawaited(context.pushRoute(const DriftAlbumsRoute())); } catch (e) { @@ -152,7 +153,7 @@ class _RemoteAlbumPageState extends ConsumerState { ImmichToast.show( context: context, - msg: 'album_viewer_appbar_share_err_delete'.t(context: context), + msg: context.t.album_viewer_appbar_share_err_delete, toastType: ToastType.error, ); } @@ -280,11 +281,7 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> { return; } - ImmichToast.show( - context: context, - msg: 'album_update_error'.t(context: context), - toastType: ToastType.error, - ); + ImmichToast.show(context: context, msg: context.t.errors.unable_to_update_album_info, toastType: ToastType.error); } } @@ -307,14 +304,14 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> { children: [ Icon(Icons.edit_outlined, color: context.colorScheme.primary, size: 24), const SizedBox(width: 12), - Text('edit_album'.t(context: context), style: context.textTheme.titleMedium), + Text(context.t.edit_album, style: context.textTheme.titleMedium), ], ), const SizedBox(height: 24), // Album Name Text( - 'album_name'.t(context: context).toUpperCase(), + context.t.album_name.toUpperCase(), style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 4), @@ -329,7 +326,7 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> { ), validator: (value) { if (value == null || value.trim().isEmpty) { - return 'album_name_required'.t(context: context); + return context.t.name_required; } return null; @@ -339,7 +336,7 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> { // Description Text( - 'description'.t(context: context).toUpperCase(), + context.t.description.toUpperCase(), style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 4), @@ -359,15 +356,9 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(null), - child: Text('cancel'.t(context: context)), - ), + TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(context.t.cancel)), const SizedBox(width: 12), - FilledButton( - onPressed: _handleSave, - child: Text('save'.t(context: context)), - ), + FilledButton(onPressed: _handleSave, child: Text(context.t.save)), ], ), ], @@ -431,7 +422,7 @@ class _AlbumKebabMenu extends ConsumerWidget { return FutureBuilder( future: ref - .read(remoteAlbumServiceProvider) + .watch(remoteAlbumServiceProvider) .getUserRole(album.id, user?.id ?? '') .then((role) => role == AlbumUserRole.editor), builder: (context, snapshot) { diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 81c09bea67..2662c60a30 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -7,11 +7,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; @@ -93,8 +94,8 @@ class _DriftSlideshowPageState extends ConsumerState with Si if (asset.isImage) { _createTimer(); - } else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) { - unawaited(ref.read(videoPlayerProvider(asset.heroTag).notifier).play()); + } else if (ref.read(videoPlayerProvider(asset.id)).status == VideoPlaybackStatus.paused) { + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).play()); } else { unawaited(_nextPage()); } @@ -113,7 +114,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si final asset = widget.timeline.getAssetSafe(_index)!; if (!asset.isImage) { - unawaited(ref.read(videoPlayerProvider(asset.heroTag).notifier).pause()); + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).pause()); } setState(() { @@ -295,14 +296,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si color: context.colorScheme.primary, ); } else { - return LinearProgressIndicator( - color: context.colorScheme.primary, - borderRadius: BorderRadius.zero, - minHeight: 5, - value: - ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds / - asset.duration.inMilliseconds, - ); + return _VideoProgressBar(asset: asset); } } @@ -374,25 +368,13 @@ class _DriftSlideshowPageState extends ConsumerState with Si builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)), ); } else { - final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status)); - final position = ref.read(videoPlayerProvider(asset.heroTag)).position; - - if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) { - unawaited(_nextPage()); - } else if (status == VideoPlaybackStatus.playing) { - unawaited(ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false)); - } - - return PhotoView.customChild( - onTapUp: (_, _, _) => _onTapUp(), - disableScaleGestures: true, - filterQuality: FilterQuality.high, - initialScale: scale, - child: NativeVideoViewer( - asset: asset, - isCurrent: isCurrent, - image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), - ), + return _VideoChild( + asset: asset, + isCurrent: isCurrent, + scale: scale, + imageProvider: imageProvider, + onTapUp: _onTapUp, + onCompleted: _nextPage, ); } } @@ -411,7 +393,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si children: [ AppBar( backgroundColor: context.scaffoldBackgroundColor, - title: Text("slideshow".t(context: context)), + title: Text(context.t.slideshow), actions: [ IconButton( onPressed: _paused ? _play : _pause, @@ -477,6 +459,67 @@ class _DriftSlideshowPageState extends ConsumerState with Si } } +class _VideoChild extends ConsumerWidget { + final BaseAsset asset; + final bool isCurrent; + final PhotoViewComputedScale scale; + final ImageProvider imageProvider; + final VoidCallback onTapUp; + final VoidCallback onCompleted; + + const _VideoChild({ + required this.asset, + required this.isCurrent, + required this.scale, + required this.imageProvider, + required this.onTapUp, + required this.onCompleted, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) { + if (status == VideoPlaybackStatus.completed) { + if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) { + onCompleted(); + } + } else if (status == VideoPlaybackStatus.playing) { + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); + } + }); + + return PhotoView.customChild( + onTapUp: (_, _, _) => onTapUp(), + disableScaleGestures: true, + filterQuality: FilterQuality.high, + initialScale: scale, + child: NativeVideoViewer( + asset: asset, + isCurrent: isCurrent, + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + ), + ); + } +} + +class _VideoProgressBar extends ConsumerWidget { + final BaseAsset asset; + + const _VideoProgressBar({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position)); + + return LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: BorderRadius.zero, + minHeight: 5, + value: position.inMilliseconds / asset.duration.inMilliseconds, + ); + } +} + /// Progress bar for image slides, driven by an explicit [AnimationController]. /// /// [TweenAnimationBuilder] creates its controller internally with the default diff --git a/mobile/lib/presentation/pages/drift_trash.page.dart b/mobile/lib/presentation/pages/drift_trash.page.dart index 36db74a658..4dd027494d 100644 --- a/mobile/lib/presentation/pages/drift_trash.page.dart +++ b/mobile/lib/presentation/pages/drift_trash.page.dart @@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart'; @@ -35,7 +34,7 @@ class DriftTrashPage extends StatelessWidget { ], child: Timeline( appBar: SliverAppBar( - title: Text('trash'.t(context: context)), + title: Text(context.t.trash), floating: true, snap: true, pinned: true, diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart index 19a450b435..c11d5a1fd6 100644 --- a/mobile/lib/presentation/pages/drift_user_selection.page.dart +++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -9,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -52,6 +52,7 @@ class DriftUserSelectionPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final AsyncValue> suggestedShareUsers = ref.watch(driftUsersProvider); + final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id)); final sharedUsersList = useState>({}); void addNewUsersHandler() { @@ -86,7 +87,7 @@ class DriftUserSelectionPage extends HookConsumerWidget { Padding( padding: const EdgeInsets.all(16.0), child: Text( - 'suggestions'.tr(), + context.t.suggestions, style: const TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold), ), ), @@ -118,7 +119,7 @@ class DriftUserSelectionPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: const Text('invite_to_album').tr(), + title: Text(context.t.invite_to_album), elevation: 0, centerTitle: false, leading: IconButton( @@ -130,15 +131,12 @@ class DriftUserSelectionPage extends HookConsumerWidget { actions: [ TextButton( onPressed: sharedUsersList.value.isEmpty ? null : () => addNewUsersHandler(), - child: const Text("add", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(), + child: Text(context.t.add, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), ), ], ), body: suggestedShareUsers.widgetWhen( onData: (users) { - // Get shared users for this album from the database - final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id)); - return sharedUsers.when( data: (albumSharedUsers) { // Filter out users that are already shared with this album and the owner diff --git a/mobile/lib/presentation/pages/drift_video.page.dart b/mobile/lib/presentation/pages/drift_video.page.dart index eef05acdce..b51ebc25b8 100644 --- a/mobile/lib/presentation/pages/drift_video.page.dart +++ b/mobile/lib/presentation/pages/drift_video.page.dart @@ -1,7 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -26,7 +26,7 @@ class DriftVideoPage extends StatelessWidget { return timelineService; }), ], - child: Timeline(appBar: MesmerizingSliverAppBar(title: 'videos'.t())), + child: Timeline(appBar: MesmerizingSliverAppBar(title: context.t.videos)), ); } } diff --git a/mobile/lib/presentation/pages/edit/drift_edit.page.dart b/mobile/lib/presentation/pages/edit/drift_edit.page.dart index ec7d8ac3d7..6237169431 100644 --- a/mobile/lib/presentation/pages/edit/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/edit/drift_edit.page.dart @@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/aspect_ratios.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/theme/theme_data.dart'; @@ -63,14 +64,14 @@ class _DriftEditImagePageState extends ConsumerState with Ti return; } - ImmichToast.show(context: context, msg: 'success'.tr(), toastType: ToastType.success); + ImmichToast.show(context: context, msg: context.t.success, toastType: ToastType.success); Navigator.of(context).pop(); } catch (e) { if (!mounted) { return; } - ImmichToast.show(context: context, msg: 'error_title'.tr(), toastType: ToastType.error); + ImmichToast.show(context: context, msg: context.t.error_title, toastType: ToastType.error); } finally { ref.read(editorStateProvider.notifier).setIsEditing(false); } @@ -80,17 +81,17 @@ class _DriftEditImagePageState extends ConsumerState with Ti return showDialog( context: context, builder: (context) => AlertDialog( - title: Text('editor_discard_edits_title'.tr()), - content: Text('editor_discard_edits_prompt'.tr()), + title: Text(context.t.editor_discard_edits_title), + content: Text(context.t.editor_discard_edits_prompt), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), style: ButtonStyle( foregroundColor: WidgetStateProperty.all(context.themeData.colorScheme.onSurfaceVariant), ), - child: Text('cancel'.tr()), + child: Text(context.t.cancel), ), - TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text('confirm'.tr())), + TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text(context.t.confirm)), ], ), ); @@ -116,7 +117,7 @@ class _DriftEditImagePageState extends ConsumerState with Ti child: Scaffold( appBar: AppBar( backgroundColor: Colors.black, - title: Text("edit".tr()), + title: Text(context.t.edit), leading: ImmichCloseButton(onPressed: () => Navigator.of(context).maybePop()), actions: [_SaveEditsButton(onSave: _saveEditedImage)], ), @@ -220,7 +221,7 @@ class _AspectRatioSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final editorState = ref.watch(editorStateProvider); - final editorNotifier = ref.read(editorStateProvider.notifier); + final editorNotifier = ref.watch(editorStateProvider.notifier); return SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -245,7 +246,7 @@ class _TransformControls extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final editorNotifier = ref.read(editorStateProvider.notifier); + final editorNotifier = ref.watch(editorStateProvider.notifier); return Column( mainAxisSize: MainAxisSize.min, @@ -333,10 +334,10 @@ class _ResetEditsButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final editorState = ref.watch(editorStateProvider); - final editorNotifier = ref.read(editorStateProvider.notifier); + final editorNotifier = ref.watch(editorStateProvider.notifier); return ImmichTextButton( - labelText: 'reset'.tr(), + labelText: context.t.reset, onPressed: editorNotifier.resetEdits, variant: ImmichVariant.ghost, expanded: false, @@ -384,7 +385,7 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi @override Widget build(BuildContext context) { final editorState = ref.watch(editorStateProvider); - final editorNotifier = ref.read(editorStateProvider.notifier); + final editorNotifier = ref.watch(editorStateProvider.notifier); ref.listen(editorStateProvider, (previous, current) { // Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation diff --git a/mobile/lib/presentation/pages/edit/editor.provider.dart b/mobile/lib/presentation/pages/edit/editor.provider.dart index 3d97f2173f..39cf4cc3ff 100644 --- a/mobile/lib/presentation/pages/edit/editor.provider.dart +++ b/mobile/lib/presentation/pages/edit/editor.provider.dart @@ -1,10 +1,14 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/aspect_ratios.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/utils/editor.utils.dart'; +part 'editor.provider.freezed.dart'; + final editorStateProvider = NotifierProvider(EditorProvider.new); class EditorProvider extends Notifier { @@ -32,8 +36,8 @@ class EditorProvider extends Notifier { final transform = normalizeTransformEdits(edits); state = state.copyWith( - originalWidth: originalWidth, - originalHeight: originalHeight, + originalWidth: originalWidth ?? state.originalWidth, + originalHeight: originalHeight ?? state.originalHeight, crop: crop, flipHorizontal: transform.mirrorHorizontal, flipVertical: transform.mirrorVertical, @@ -106,104 +110,24 @@ class EditorProvider extends Notifier { } } -class EditorState { - final bool isApplyingEdits; +@freezed +abstract class EditorState with _$EditorState { + const EditorState._(); - final int rotationAngle; - final bool flipHorizontal; - final bool flipVertical; - final Rect crop; - final CropAspectRatio aspectRatio; - - final int originalWidth; - final int originalHeight; - - final Duration animationDuration; - - final bool hasUnsavedEdits; - - const EditorState({ - bool? isApplyingEdits, - int? rotationAngle, - bool? flipHorizontal, - bool? flipVertical, - Rect? crop, - CropAspectRatio? aspectRatio, - int? originalWidth, - int? originalHeight, - Duration? animationDuration, - bool? hasUnsavedEdits, - }) : isApplyingEdits = isApplyingEdits ?? false, - rotationAngle = rotationAngle ?? 0, - flipHorizontal = flipHorizontal ?? false, - flipVertical = flipVertical ?? false, - animationDuration = animationDuration ?? Duration.zero, - originalWidth = originalWidth ?? 0, - originalHeight = originalHeight ?? 0, - crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1), - aspectRatio = aspectRatio ?? CropAspectRatio.free, - hasUnsavedEdits = hasUnsavedEdits ?? false; - - EditorState copyWith({ - bool? isApplyingEdits, - int? rotationAngle, - bool? flipHorizontal, - bool? flipVertical, - CropAspectRatio? aspectRatio, - int? originalWidth, - int? originalHeight, - Duration? animationDuration, - Rect? crop, - bool? hasUnsavedEdits, - }) { - return EditorState( - isApplyingEdits: isApplyingEdits ?? this.isApplyingEdits, - rotationAngle: rotationAngle ?? this.rotationAngle, - flipHorizontal: flipHorizontal ?? this.flipHorizontal, - flipVertical: flipVertical ?? this.flipVertical, - aspectRatio: aspectRatio ?? this.aspectRatio, - animationDuration: animationDuration ?? this.animationDuration, - originalWidth: originalWidth ?? this.originalWidth, - originalHeight: originalHeight ?? this.originalHeight, - crop: crop ?? this.crop, - hasUnsavedEdits: hasUnsavedEdits ?? this.hasUnsavedEdits, - ); - } + const factory EditorState({ + @Default(false) bool isApplyingEdits, + @Default(0) int rotationAngle, + @Default(false) bool flipHorizontal, + @Default(false) bool flipVertical, + @Default(Rect.fromLTRB(0, 0, 1, 1)) Rect crop, + @Default(CropAspectRatio.free) CropAspectRatio aspectRatio, + @Default(0) int originalWidth, + @Default(0) int originalHeight, + @Default(Duration.zero) Duration animationDuration, + @Default(false) bool hasUnsavedEdits, + }) = _EditorState; bool get hasEdits { return rotationAngle != 0 || flipHorizontal || flipVertical || crop != const Rect.fromLTRB(0, 0, 1, 1); } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is EditorState && - other.isApplyingEdits == isApplyingEdits && - other.rotationAngle == rotationAngle && - other.flipHorizontal == flipHorizontal && - other.flipVertical == flipVertical && - other.crop == crop && - other.aspectRatio == aspectRatio && - other.originalWidth == originalWidth && - other.originalHeight == originalHeight && - other.animationDuration == animationDuration && - other.hasUnsavedEdits == hasUnsavedEdits; - } - - @override - int get hashCode { - return isApplyingEdits.hashCode ^ - rotationAngle.hashCode ^ - flipHorizontal.hashCode ^ - flipVertical.hashCode ^ - crop.hashCode ^ - aspectRatio.hashCode ^ - originalWidth.hashCode ^ - originalHeight.hashCode ^ - animationDuration.hashCode ^ - hasUnsavedEdits.hashCode; - } } diff --git a/mobile/lib/presentation/pages/feature_message/whats_new.page.dart b/mobile/lib/presentation/pages/feature_message/whats_new.page.dart index e3c3f8ed6e..d9e4e5c6eb 100644 --- a/mobile/lib/presentation/pages/feature_message/whats_new.page.dart +++ b/mobile/lib/presentation/pages/feature_message/whats_new.page.dart @@ -1,5 +1,4 @@ import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/feature_message.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -18,7 +17,7 @@ class WhatsNewPage extends StatelessWidget { body: ListView.separated( padding: const EdgeInsets.only(top: 16, bottom: 64), itemCount: highlights.length, - separatorBuilder: (_, __) => const SizedBox(height: 24), + separatorBuilder: (_, _) => const SizedBox(height: 24), itemBuilder: (_, index) => _HighlightCard(highlight: highlights[index]), ), ); @@ -54,16 +53,16 @@ class _HighlightCard extends StatelessWidget { : Image.asset( highlight.image!, fit: BoxFit.contain, - errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(), + errorBuilder: (context, _, _) => const FeatureMessagePlaceholder(), ), ), ), ), const SizedBox(height: 12), - Text(highlight.titleKey.tr(), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + Text(highlight.title(context.t), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text( - highlight.bodyKey.tr(), + highlight.body(context.t), style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), ), ], diff --git a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart index a987c9ca29..a5c069671c 100644 --- a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart +++ b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart @@ -2,13 +2,13 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:crop_image/crop_image.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -70,6 +70,10 @@ class _ProfilePictureCropPageState extends ConsumerState final croppedImage = await _cropController.croppedImage(); final pngBytes = await imageToUint8List(croppedImage); final xFile = XFile.fromData(pngBytes, mimeType: 'image/png'); + if (!context.mounted) { + return; + } + final success = await ref .read(uploadProfileImageProvider.notifier) .upload(xFile, fileName: 'profile-picture.png'); @@ -89,7 +93,7 @@ class _ProfilePictureCropPageState extends ConsumerState ImmichToast.show( context: context, - msg: 'profile_picture_set'.tr(), + msg: context.t.profile_picture_set, gravity: ToastGravity.BOTTOM, toastType: ToastType.success, ); @@ -98,7 +102,7 @@ class _ProfilePictureCropPageState extends ConsumerState } else { ImmichToast.show( context: context, - msg: 'errors.unable_to_set_profile_picture'.tr(), + msg: context.t.errors.unable_to_set_profile_picture, toastType: ToastType.error, gravity: ToastGravity.BOTTOM, ); @@ -110,7 +114,7 @@ class _ProfilePictureCropPageState extends ConsumerState ImmichToast.show( context: context, - msg: 'errors.unable_to_set_profile_picture'.tr(), + msg: context.t.errors.unable_to_set_profile_picture, toastType: ToastType.error, gravity: ToastGravity.BOTTOM, ); @@ -131,7 +135,7 @@ class _ProfilePictureCropPageState extends ConsumerState return Scaffold( appBar: AppBar( backgroundColor: context.scaffoldBackgroundColor, - title: Text("set_profile_picture".tr()), + title: Text(context.t.set_profile_picture), leading: _isLoading ? null : const ImmichCloseButton(), actions: [ if (_isLoading) diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 39588cf051..e364e0d2f8 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; @@ -48,9 +47,7 @@ class DriftSearchPage extends HookConsumerWidget { serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename, ); final searchHintText = useState( - serverFeatures.smartSearch - ? 'sunrise_on_the_beach'.t(context: context) - : 'file_name_or_extension'.t(context: context), + serverFeatures.smartSearch ? context.t.sunrise_on_the_beach : context.t.file_name_or_extension, ); final textSearchController = useTextEditingController(); final filter = useState( @@ -59,7 +56,7 @@ class DriftSearchPage extends HookConsumerWidget { location: SearchLocationFilter(), camera: SearchCameraFilter(), date: SearchDateFilter(), - display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + display: const SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), rating: SearchRatingFilter(), mediaType: AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", @@ -132,7 +129,7 @@ class DriftSearchPage extends HookConsumerWidget { void showPeoplePicker() { var people = filter.value.people; - void handleOnSelect(Set value) { + void handleOnSelect(Set value) { people = value; } @@ -142,7 +139,7 @@ class DriftSearchPage extends HookConsumerWidget { } void handleApply() { - final label = people.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '); + final label = people.map((e) => e.name != '' ? e.name : context.t.no_name).join(', '); peopleCurrentFilterWidget.value = label.isNotEmpty ? Text(label, style: context.textTheme.labelLarge) : null; search(filter.value.copyWith(people: people)); } @@ -154,7 +151,7 @@ class DriftSearchPage extends HookConsumerWidget { child: FractionallySizedBox( heightFactor: 0.8, child: FilterBottomSheetScaffold( - title: 'search_filter_people_title'.t(context: context), + title: context.t.search_filter_people_title, expanded: true, onSearch: handleApply, onClear: handleClear, @@ -191,7 +188,7 @@ class DriftSearchPage extends HookConsumerWidget { child: FractionallySizedBox( heightFactor: 0.8, child: FilterBottomSheetScaffold( - title: 'search_filter_tags_title'.t(context: context), + title: context.t.search_filter_tags_title, expanded: true, onSearch: handleApply, onClear: handleClear, @@ -232,7 +229,7 @@ class DriftSearchPage extends HookConsumerWidget { isScrollControlled: true, isDismissible: true, child: FilterBottomSheetScaffold( - title: 'search_filter_location_title'.t(context: context), + title: context.t.search_filter_location_title, onSearch: handleApply, onClear: handleClear, child: Padding( @@ -277,7 +274,7 @@ class DriftSearchPage extends HookConsumerWidget { isScrollControlled: true, isDismissible: true, child: FilterBottomSheetScaffold( - title: 'search_filter_camera_title'.t(context: context), + title: context.t.search_filter_camera_title, onSearch: handleApply, onClear: handleClear, child: Padding( @@ -333,14 +330,14 @@ class DriftSearchPage extends HookConsumerWidget { lastDate: lastDate, currentDate: DateTime.now(), initialDateRange: dateRange, - helpText: 'search_filter_date_title'.t(context: context), - cancelText: 'cancel'.t(context: context), - confirmText: 'select'.t(context: context), - saveText: 'save'.t(context: context), - errorFormatText: 'invalid_date_format'.t(context: context), - errorInvalidText: 'invalid_date'.t(context: context), - fieldStartHintText: 'start_date'.t(context: context), - fieldEndHintText: 'end_date'.t(context: context), + helpText: context.t.search_filter_date_title, + cancelText: context.t.cancel, + confirmText: context.t.select, + saveText: context.t.save, + errorFormatText: context.t.invalid_date_format, + errorInvalidText: context.t.invalid_date, + fieldStartHintText: context.t.start_date, + fieldEndHintText: context.t.end_date, initialEntryMode: DatePickerEntryMode.calendar, keyboardType: TextInputType.text, ); @@ -357,7 +354,7 @@ class DriftSearchPage extends HookConsumerWidget { showFilterBottomSheet( context: context, child: FilterBottomSheetScaffold( - title: "pick_date_range".tr(), + title: context.t.pick_date_range, expanded: true, onClear: () => datePicked(null), child: QuickDatePicker( @@ -392,7 +389,7 @@ class DriftSearchPage extends HookConsumerWidget { void handleApply() { mediaTypeCurrentFilterWidget.value = mediaType != AssetType.other ? Text( - mediaType == AssetType.image ? 'image'.t(context: context) : 'video'.t(context: context), + mediaType == AssetType.image ? context.t.image : context.t.video, style: context.textTheme.labelLarge, ) : null; @@ -403,7 +400,7 @@ class DriftSearchPage extends HookConsumerWidget { showFilterBottomSheet( context: context, child: FilterBottomSheetScaffold( - title: 'search_filter_media_type_title'.t(context: context), + title: context.t.search_filter_media_type_title, onSearch: handleApply, onClear: handleClear, child: MediaTypePicker(onSelect: handleOnSelected, filter: filter.value.mediaType), @@ -427,10 +424,7 @@ class DriftSearchPage extends HookConsumerWidget { void handleApply() { ratingCurrentFilterWidget.value = rating.rating.isSome - ? Text( - 'rating_count'.t(args: {'count': rating.rating.unwrapOrNull ?? 0}), - style: context.textTheme.labelLarge, - ) + ? Text(context.t.rating_count(count: rating.rating.unwrapOrNull ?? 0), style: context.textTheme.labelLarge) : null; search(filter.value.copyWith(rating: rating)); } @@ -440,7 +434,7 @@ class DriftSearchPage extends HookConsumerWidget { context: context, isScrollControlled: true, child: FilterBottomSheetScaffold( - title: 'rating'.t(context: context), + title: context.t.rating, onSearch: handleApply, onClear: handleClear, child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating), @@ -455,9 +449,9 @@ class DriftSearchPage extends HookConsumerWidget { void handleOnSelect(Map value) { display = display.copyWith( - isNotInAlbum: value[DisplayOption.notInAlbum], - isArchive: value[DisplayOption.archive], - isFavorite: value[DisplayOption.favorite], + isNotInAlbum: value[DisplayOption.notInAlbum] ?? display.isNotInAlbum, + isArchive: value[DisplayOption.archive] ?? display.isArchive, + isFavorite: value[DisplayOption.favorite] ?? display.isFavorite, ); } @@ -465,16 +459,16 @@ class DriftSearchPage extends HookConsumerWidget { displayOptionCurrentFilterWidget.value = null; search( filter.value.copyWith( - display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + display: const SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), ), ); } void handleApply() { final filterText = [ - if (display.isNotInAlbum) 'search_filter_display_option_not_in_album'.t(context: context), - if (display.isArchive) 'archive'.t(context: context), - if (display.isFavorite) 'favorite'.t(context: context), + if (display.isNotInAlbum) context.t.search_filter_display_option_not_in_album, + if (display.isArchive) context.t.archive, + if (display.isFavorite) context.t.favorite, ]; displayOptionCurrentFilterWidget.value = filterText.isNotEmpty ? Text(filterText.join(', '), style: context.textTheme.labelLarge) @@ -486,7 +480,7 @@ class DriftSearchPage extends HookConsumerWidget { showFilterBottomSheet( context: context, child: FilterBottomSheetScaffold( - title: 'display_options'.t(context: context), + title: context.t.display_options, onSearch: handleApply, onClear: handleClear, child: DisplayOptionPicker(onSelect: handleOnSelect, filter: filter.value.display), @@ -534,7 +528,7 @@ class DriftSearchPage extends HookConsumerWidget { } }, icon: const Icon(Icons.more_vert_rounded), - tooltip: 'show_text_search_menu'.tr(), + tooltip: context.t.show_text_search_menu, ); }, menuChildren: [ @@ -544,7 +538,7 @@ class DriftSearchPage extends HookConsumerWidget { child: ListTile( leading: const Icon(Icons.image_search_rounded), title: Text( - 'search_by_context'.t(context: context), + context.t.search_by_context, style: context.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null, @@ -555,7 +549,7 @@ class DriftSearchPage extends HookConsumerWidget { ), onPressed: () { textSearchType.value = TextSearchType.context; - searchHintText.value = 'sunrise_on_the_beach'.t(context: context); + searchHintText.value = context.t.sunrise_on_the_beach; }, ), ), @@ -563,7 +557,7 @@ class DriftSearchPage extends HookConsumerWidget { child: ListTile( leading: const Icon(Icons.abc_rounded), title: Text( - 'search_filter_filename'.t(context: context), + context.t.search_filter_filename, style: context.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, color: textSearchType.value == TextSearchType.filename ? context.colorScheme.primary : null, @@ -574,14 +568,14 @@ class DriftSearchPage extends HookConsumerWidget { ), onPressed: () { textSearchType.value = TextSearchType.filename; - searchHintText.value = 'file_name_or_extension'.t(context: context); + searchHintText.value = context.t.file_name_or_extension; }, ), MenuItemButton( child: ListTile( leading: const Icon(Icons.text_snippet_outlined), title: Text( - 'search_by_description'.t(context: context), + context.t.search_by_description, style: context.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, color: textSearchType.value == TextSearchType.description ? context.colorScheme.primary : null, @@ -592,7 +586,7 @@ class DriftSearchPage extends HookConsumerWidget { ), onPressed: () { textSearchType.value = TextSearchType.description; - searchHintText.value = 'search_by_description_example'.t(context: context); + searchHintText.value = context.t.search_by_description_example; }, ), FeatureCheck( @@ -601,7 +595,7 @@ class DriftSearchPage extends HookConsumerWidget { child: ListTile( leading: const Icon(Icons.document_scanner_outlined), title: Text( - 'search_by_ocr'.t(context: context), + context.t.search_by_ocr, style: context.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null, @@ -612,7 +606,7 @@ class DriftSearchPage extends HookConsumerWidget { ), onPressed: () { textSearchType.value = TextSearchType.ocr; - searchHintText.value = 'search_by_ocr_example'.t(context: context); + searchHintText.value = context.t.search_by_ocr_example; }, ), ), @@ -663,52 +657,52 @@ class DriftSearchPage extends HookConsumerWidget { SearchFilterChip( icon: Icons.people_alt_outlined, onTap: showPeoplePicker, - label: 'people'.t(context: context), + label: context.t.people, currentFilter: peopleCurrentFilterWidget.value, ), SearchFilterChip( icon: Icons.location_on_outlined, onTap: showLocationPicker, - label: 'search_filter_location'.t(context: context), + label: context.t.search_filter_location, currentFilter: locationCurrentFilterWidget.value, ), if (userPreferences.valueOrNull?.tagsEnabled ?? false) SearchFilterChip( icon: Icons.sell_outlined, onTap: showTagPicker, - label: 'tags'.t(context: context), + label: context.t.tags, currentFilter: tagCurrentFilterWidget.value, ), SearchFilterChip( icon: Icons.camera_alt_outlined, onTap: showCameraPicker, - label: 'camera'.t(context: context), + label: context.t.camera, currentFilter: cameraCurrentFilterWidget.value, ), SearchFilterChip( icon: Icons.date_range_outlined, onTap: showQuickDatePicker, - label: 'search_filter_date'.t(context: context), + label: context.t.search_filter_date, currentFilter: dateRangeCurrentFilterWidget.value, ), SearchFilterChip( key: const Key('media_type_chip'), icon: Icons.video_collection_outlined, onTap: showMediaTypePicker, - label: 'search_filter_media_type'.t(context: context), + label: context.t.search_filter_media_type, currentFilter: mediaTypeCurrentFilterWidget.value, ), if (userPreferences.valueOrNull?.ratingsEnabled ?? false) SearchFilterChip( icon: Icons.star_outline_rounded, onTap: showStarRatingPicker, - label: 'search_filter_star_rating'.t(context: context), + label: context.t.search_filter_star_rating, currentFilter: ratingCurrentFilterWidget.value, ), SearchFilterChip( icon: Icons.display_settings_outlined, onTap: showDisplayOptionPicker, - label: 'search_filter_display_options'.t(context: context), + label: context.t.search_filter_display_options, currentFilter: displayOptionCurrentFilterWidget.value, ), ], @@ -748,9 +742,7 @@ class _SearchResultGrid extends ConsumerWidget { return false; } - Widget? _bottomWidget(BuildContext context, WidgetRef ref) { - final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); - + Widget? _bottomWidget(BuildContext context, {required bool isLoading, required bool hasMore}) { if (isLoading) { return const SliverFillRemaining( hasScrollBody: false, @@ -761,8 +753,6 @@ class _SearchResultGrid extends ConsumerWidget { ); } - final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); - if (hasMore) { return null; } @@ -772,7 +762,7 @@ class _SearchResultGrid extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 32), child: Center( child: Text( - 'search_no_more_result'.t(context: context), + context.t.search_no_more_result, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), ), ), @@ -784,6 +774,7 @@ class _SearchResultGrid extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); if (!hasAssets && !isLoading) { return const _SearchNoResults(); @@ -813,7 +804,7 @@ class _SearchResultGrid extends ConsumerWidget { bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), snapToMonth: false, loadingWidget: const SizedBox.shrink(), - bottomSliverWidget: _bottomWidget(context, ref), + bottomSliverWidget: _bottomWidget(context, isLoading: isLoading, hasMore: hasMore), ), ), ), @@ -837,7 +828,7 @@ class _SearchNoResults extends StatelessWidget { Icon(Icons.search_off_rounded, size: 72, color: context.colorScheme.onSurfaceVariant), const SizedBox(height: 24), Text( - 'search_no_result'.t(context: context), + context.t.search_no_result, textAlign: TextAlign.center, style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant), ), @@ -865,9 +856,7 @@ class _SearchSuggestions extends StatelessWidget { ), ), const SizedBox(height: 16), - Center( - child: Text('search_page_search_photos_videos'.t(context: context), style: context.textTheme.labelLarge), - ), + Center(child: Text(context.t.search_page_search_photos_videos, style: context.textTheme.labelLarge)), const SizedBox(height: 32), const Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: _QuickLinkList()), ], @@ -900,7 +889,7 @@ class _QuickLinkList extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), children: [ _QuickLink( - title: 'recently_taken'.t(context: context), + title: context.t.recently_taken, icon: Icons.schedule_outlined, isTop: true, onTap: () => context.pushRoute(const DriftRecentlyTakenRoute()), @@ -911,12 +900,12 @@ class _QuickLinkList extends StatelessWidget { onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()), ), _QuickLink( - title: 'videos'.t(context: context), + title: context.t.videos, icon: Icons.play_circle_outline_rounded, onTap: () => context.pushRoute(const DriftVideoRoute()), ), _QuickLink( - title: 'favorites'.t(context: context), + title: context.t.favorites, icon: Icons.favorite_border_rounded, isBottom: true, onTap: () => context.pushRoute(const DriftFavoriteRoute()), diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index cfb5887c7c..2466a52ead 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -1,13 +1,12 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/lock.action.dart'; @@ -52,11 +51,11 @@ class _AddActionButtonState extends ConsumerState { return [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium), + child: Text(context.t.add_to_bottom_bar, style: context.textTheme.labelMedium), ), BaseActionButton( iconData: Icons.photo_album_outlined, - label: "album".tr(), + label: context.t.album, menuItem: true, onPressed: () => _handleMenuSelection(AddToMenuItem.album), ), @@ -65,7 +64,7 @@ class _AddActionButtonState extends ConsumerState { const Divider(), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text("move_to".tr(), style: context.textTheme.labelMedium), + child: Text(context.t.move_to, style: context.textTheme.labelMedium), ), const ActionMenuItem(action: ArchiveAction(source: .viewer)), const ActionMenuItem(action: LockAction(source: .viewer)), @@ -120,7 +119,7 @@ class _AddActionButtonState extends ConsumerState { } if (!result.success) { - ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error); + ImmichToast.show(context: context, msg: context.t.scaffold_body_error_occurred, toastType: ToastType.error); return; } @@ -128,7 +127,7 @@ class _AddActionButtonState extends ConsumerState { if (result.count > 0) { ImmichToast.show( context: context, - msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + msg: context.t.add_to_album_bottom_sheet_added(album: album.name), ); // Refresh the "Appears in" list on the asset's info panel. @@ -136,13 +135,13 @@ class _AddActionButtonState extends ConsumerState { } else if (result.failedCount > 0) { ImmichToast.show( context: context, - msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failedCount}), + msg: context.t.assets_cannot_be_added_to_album_count(count: result.failedCount), toastType: ToastType.error, ); } else { ImmichToast.show( context: context, - msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}), + msg: context.t.add_to_album_bottom_sheet_already_exists(album: album.name), ); } @@ -183,7 +182,7 @@ class _AddActionButtonState extends ConsumerState { builder: (context, controller, child) { return BaseActionButton( iconData: Icons.add, - label: "add_to_bottom_bar".tr(), + label: context.t.add_to_bottom_bar, onPressed: () => controller.isOpen ? controller.close() : controller.open(), ); }, diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index ee2dace729..bb25fc4739 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -class BaseActionButton extends ConsumerWidget { +class BaseActionButton extends StatelessWidget { const BaseActionButton({ super.key, required this.label, @@ -31,7 +30,7 @@ class BaseActionButton extends ConsumerWidget { final void Function()? onLongPressed; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final iconTheme = IconTheme.of(context); final iconSize = iconTheme.size ?? 24.0; diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index 46a5c2c41e..e39a76ef02 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -1,9 +1,8 @@ import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; @@ -48,7 +47,7 @@ class LikeActivityActionButton extends ConsumerWidget { return BaseActionButton( maxWidth: 60, iconData: liked != null ? Icons.thumb_up : Icons.thumb_up_off_alt, - label: "like".t(context: context), + label: context.t.like, onPressed: () => onTap(liked), iconOnly: iconOnly, menuItem: menuItem, @@ -58,11 +57,11 @@ class LikeActivityActionButton extends ConsumerWidget { // default to empty heart during loading loading: () => BaseActionButton( iconData: Icons.thumb_up_off_alt, - label: "like".t(context: context), + label: context.t.like, iconOnly: iconOnly, menuItem: menuItem, ), - error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])), + error: (error, stack) => Text(context.t.error_saving_image(error: error.toString())), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart index 3bd67978e2..6be8a6b023 100644 --- a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; @@ -16,8 +16,8 @@ class MotionPhotoActionButton extends ConsumerWidget { return BaseActionButton( iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded, - label: "play_motion_photo".t(context: context), - onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, + label: context.t.play_motion_photo, + onPressed: ref.watch(isPlayingMotionVideoProvider.notifier).toggle, iconOnly: iconOnly, menuItem: menuItem, ); diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index f30796e406..521f7d0f77 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -2,14 +2,13 @@ import 'dart:async'; import 'dart:math'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart'; @@ -326,7 +325,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { ), ), child: Text( - sortMode.label.t(context: context), + sortMode.label(context.t), style: context.textTheme.labelLarge?.copyWith( color: albumSortOption == sortMode ? context.colorScheme.onPrimary @@ -354,7 +353,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { : Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface), ), Text( - albumSortOption.label.t(context: context), + albumSortOption.label(context.t), style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(225)), ), isSorting @@ -413,7 +412,7 @@ class _SearchBar extends StatelessWidget { child: SearchField( autofocus: false, contentPadding: const EdgeInsets.all(16), - hintText: 'search_albums'.tr(), + hintText: context.t.search_albums, prefixIcon: const Icon(Icons.search_rounded), suffixIcon: searchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear_rounded), onPressed: onClearSearch) @@ -452,7 +451,7 @@ class _QuickFilterButtonRow extends StatelessWidget { runSpacing: 4, children: [ _QuickFilterButton( - label: 'all'.tr(), + label: context.t.all, isSelected: filterMode == QuickFilterMode.all, onTap: () { onChangeFilter(QuickFilterMode.all); @@ -460,7 +459,7 @@ class _QuickFilterButtonRow extends StatelessWidget { }, ), _QuickFilterButton( - label: 'shared_with_me'.tr(), + label: context.t.shared_with_me, isSelected: filterMode == QuickFilterMode.sharedWithMe, onTap: () { onChangeFilter(QuickFilterMode.sharedWithMe); @@ -468,7 +467,7 @@ class _QuickFilterButtonRow extends StatelessWidget { }, ), _QuickFilterButton( - label: 'my_albums'.tr(), + label: context.t.my_albums, isSelected: filterMode == QuickFilterMode.myAlbums, onTap: () { onChangeFilter(QuickFilterMode.myAlbums); @@ -571,7 +570,7 @@ class _AlbumList extends ConsumerWidget { if (albums.isEmpty) { return SliverToBoxAdapter( child: Center( - child: Padding(padding: const EdgeInsets.all(20.0), child: Text('album_search_not_found'.tr())), + child: Padding(padding: const EdgeInsets.all(20.0), child: Text(context.t.album_search_not_found)), ), ); } @@ -600,9 +599,9 @@ class _AlbumList extends ConsumerWidget { context: context, builder: (context) => ConfirmDialog( onOk: () => true, - title: "delete_album".t(context: context), - content: "album_delete_confirmation".t(context: context, args: {'album': album.name}), - ok: "delete".t(context: context), + title: context.t.delete_album, + content: context.t.album_delete_confirmation(album: album.name), + ok: context.t.delete, ), ); }, @@ -637,7 +636,7 @@ class _AlbumGrid extends StatelessWidget { if (albums.isEmpty) { return SliverToBoxAdapter( child: Center( - child: Padding(padding: const EdgeInsets.all(20.0), child: Text('album_search_not_found'.tr())), + child: Padding(padding: const EdgeInsets.all(20.0), child: Text(context.t.album_search_not_found)), ), ); } @@ -669,7 +668,7 @@ class _GridAlbumCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); + final albumThumbnailAsset = ref.watch(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); return GestureDetector( onTap: () => onAlbumSelected(album), @@ -723,7 +722,7 @@ class _GridAlbumCard extends ConsumerWidget { style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), ), Text( - '${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}', + '${context.t.items_count(count: album.assetCount)} • ${album.ownerId != userId ? context.t.shared_by_user(user: album.ownerName) : context.t.owned}', maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), @@ -746,7 +745,7 @@ class AddToAlbumHeader extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { Future onCreateAlbum() async { final albumName = await showDialog(context: context, builder: (context) => const NewAlbumNameModal()); - if (albumName == null) { + if (albumName == null || !context.mounted) { return; } @@ -760,7 +759,7 @@ class AddToAlbumHeader extends ConsumerWidget { } if (newAlbum == null) { - ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr()); + ImmichToast.show(context: context, toastType: ToastType.error, msg: context.t.errors.failed_to_create_album); return; } @@ -774,7 +773,7 @@ class AddToAlbumHeader extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("add_to_album", style: context.textTheme.titleSmall).tr(), + Text(context.t.add_to_album, style: context.textTheme.titleSmall), TextButton.icon( style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), // remove internal padding @@ -784,9 +783,9 @@ class AddToAlbumHeader extends ConsumerWidget { onPressed: onCreateAlbum, icon: Icon(Icons.add, color: context.primaryColor), label: Text( - "common_create_new_album", + context.t.common_create_new_album, style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14), - ).tr(), + ), ), ], ), @@ -822,13 +821,13 @@ class CreateAlbumButton extends ConsumerWidget { } if (album == null) { - ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr()); + ImmichToast.show(context: context, toastType: ToastType.error, msg: context.t.errors.failed_to_create_album); return; } ImmichToast.show( context: context, - msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + msg: context.t.add_to_album_bottom_sheet_added(album: album.name), ); // Invalidate using the asset's remote ID to refresh the "Appears in" list @@ -843,7 +842,7 @@ class CreateAlbumButton extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("add_to_album", style: context.textTheme.titleSmall).tr(), + Text(context.t.add_to_album, style: context.textTheme.titleSmall), TextButton.icon( style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -853,9 +852,9 @@ class CreateAlbumButton extends ConsumerWidget { onPressed: onCreateAlbum, icon: Icon(Icons.add, color: context.primaryColor), label: Text( - "common_create_new_album", + context.t.common_create_new_album, style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14), - ).tr(), + ), ), ], ), diff --git a/mobile/lib/presentation/widgets/album/album_tile.dart b/mobile/lib/presentation/widgets/album/album_tile.dart index bbf7e11e5a..54fc27cfd1 100644 --- a/mobile/lib/presentation/widgets/album/album_tile.dart +++ b/mobile/lib/presentation/widgets/album/album_tile.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; @@ -17,7 +17,7 @@ class AlbumTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); + final albumThumbnailAsset = ref.watch(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); return LargeLeadingTile( title: Text( @@ -27,7 +27,7 @@ class AlbumTile extends ConsumerWidget { style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), ), subtitle: Text( - '${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${isOwner ? 'owned'.t(context: context) : 'shared_by_user'.t(context: context, args: {'user': album.ownerName})}', + '${context.t.items_count(count: album.assetCount)} • ${isOwner ? context.t.owned : context.t.shared_by_user(user: album.ownerName)}', overflow: TextOverflow.ellipsis, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), diff --git a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart index 691b46f80d..55024d9b2b 100644 --- a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart +++ b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart @@ -1,7 +1,7 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @@ -98,7 +98,7 @@ class _DriftActivityTextFieldState extends ConsumerState color: context.primaryColor, disabledColor: context.colorScheme.secondaryContainer, ), - hintText: !widget.isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(), + hintText: !widget.isEnabled ? context.t.shared_album_activities_input_disable : context.t.say_something, hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]), ), onEditingComplete: onEditingComplete, diff --git a/mobile/lib/presentation/widgets/album/new_album_name_modal.widget.dart b/mobile/lib/presentation/widgets/album/new_album_name_modal.widget.dart index a5e21af489..255dbd4a0c 100644 --- a/mobile/lib/presentation/widgets/album/new_album_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/album/new_album_name_modal.widget.dart @@ -1,6 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class NewAlbumNameModal extends StatefulWidget { const NewAlbumNameModal({super.key}); @@ -21,31 +21,31 @@ class _NewAlbumNameModalState extends State { @override Widget build(BuildContext context) { return AlertDialog( - title: const Text("album_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(), + title: Text(context.t.album_name, style: const TextStyle(fontWeight: FontWeight.bold)), content: SingleChildScrollView( child: TextFormField( controller: nameController, textCapitalization: TextCapitalization.words, autofocus: true, - decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()), + decoration: InputDecoration(hintText: context.t.name, border: const OutlineInputBorder()), ), ), actions: [ TextButton( onPressed: () => context.pop(null), child: Text( - "cancel", + context.t.cancel, style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold), - ).tr(), + ), ), TextButton( onPressed: () { context.pop(nameController.text.trim()); }, child: Text( - "create_album", + context.t.create_album, style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold), - ).tr(), + ), ), ], ); diff --git a/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart index 5622ed6b1b..796c4ce43a 100644 --- a/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart +++ b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; @@ -115,7 +115,7 @@ class _PendingUploadsBannerContent extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - '${'uploading'.t(context: context)} $count$percentLabel', + '${context.t.uploading} $count$percentLabel', maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), @@ -183,10 +183,7 @@ class _PendingUploadsSheet extends ConsumerWidget { child: Row( children: [ Expanded( - child: Text( - '${'uploading'.t(context: context)} (${pending.length})', - style: context.textTheme.titleMedium, - ), + child: Text('${context.t.uploading} (${pending.length})', style: context.textTheme.titleMedium), ), if (canAbort) TextButton.icon( @@ -199,14 +196,14 @@ class _PendingUploadsSheet extends ConsumerWidget { ref.read(pendingAlbumUploadsProvider(albumId).notifier).clear(); }, icon: const Icon(Icons.stop_circle_outlined, size: 18), - label: Text('cancel'.t(context: context)), + label: Text(context.t.cancel), style: TextButton.styleFrom(foregroundColor: context.colorScheme.error), ) else if (failedCount > 0) TextButton.icon( onPressed: () => ref.read(pendingAlbumUploadsProvider(albumId).notifier).clearFailed(), icon: const Icon(Icons.clear_rounded, size: 18), - label: Text('clear_failed_count'.t(context: context, args: {'count': failedCount})), + label: Text(context.t.clear_failed_count(count: failedCount)), style: TextButton.styleFrom(foregroundColor: context.colorScheme.error), ), ], @@ -217,7 +214,7 @@ class _PendingUploadsSheet extends ConsumerWidget { child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: pending.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), + separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (_, index) => _PendingUploadTile(entry: pending[index]), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart index dffda47b84..0cb2ccd254 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; @@ -52,7 +52,7 @@ class AppearsInDetails extends ConsumerWidget { spacing: 12, children: [ SheetTile( - title: 'appears_in'.t(context: context), + title: context.t.appears_in, titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), Padding( @@ -77,7 +77,7 @@ class AppearsInDetails extends ConsumerWidget { ); }, loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart index eff5705eae..9f426f6db7 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; @@ -100,7 +100,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> ImmichToast.show( context: context, - msg: 'exif_bottom_sheet_description_error'.t(context: context), + msg: context.t.exif_bottom_sheet_description_error, toastType: ToastType.error, ); } @@ -111,9 +111,9 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> @override Widget build(BuildContext context) { final currentDescription = widget.exif.description ?? ''; - final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( - context: context, - ); + final hintText = widget.isEditable + ? context.t.exif_bottom_sheet_description + : context.t.exif_bottom_sheet_no_description; if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { _controller.text = currentDescription; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart index c6d048e458..47ecb53d8a 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; @@ -75,7 +75,7 @@ class _LocationDetailsState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ SheetTile( - title: 'location'.t(context: context), + title: context.t.location, titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), trailing: hasCoordinates && editLocation != null ? const Icon(Icons.edit_location_alt, size: 20) : null, onTap: editLocation?.onAction, @@ -107,7 +107,7 @@ class _LocationDetailsState extends ConsumerState { ), if (!hasCoordinates) SheetTile( - title: "add_a_location".t(context: context), + title: context.t.add_a_location, titleStyle: context.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: context.primaryColor, diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 72db236b3c..00c20f35bd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; @@ -30,7 +30,7 @@ class PeopleDetails extends ConsumerWidget { final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id)); - Future showNameEditModal(DriftPerson person) async { + Future showNameEditModal(Person person) async { await showDialog( context: context, useRootNavigator: false, @@ -52,7 +52,7 @@ class PeopleDetails extends ConsumerWidget { Padding( padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16), child: Text( - "people".t(context: context), + context.t.people, style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), ), @@ -89,14 +89,14 @@ class PeopleDetails extends ConsumerWidget { duration: Durations.short4, ); }, - error: (error, stack) => Text("error_loading_people".t(context: context), style: context.textTheme.bodyMedium), + error: (error, stack) => Text(context.t.errors.failed_to_load_people, style: context.textTheme.bodyMedium), loading: () => const SizedBox.shrink(), ); } } class _Avatar extends StatelessWidget { - final DriftPerson person; + final Person person; final DateTime assetFileCreatedAt; final VoidCallback? onTap; final VoidCallback? onNameTap; @@ -135,7 +135,7 @@ class _Avatar extends StatelessWidget { GestureDetector( onTap: () => onNameTap?.call(), child: Text( - "add_a_name".t(context: context), + context.t.add_a_name, style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor), maxLines: 2, overflow: TextOverflow.ellipsis, diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart index 352838c761..f07fb5c112 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; @@ -32,7 +32,7 @@ class RatingDetails extends ConsumerWidget { spacing: 8, children: [ Text( - 'rating'.t(context: context), + context.t.rating, style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), RatingBar( diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart index e97fa4889b..e36cd9967b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; @@ -27,7 +27,7 @@ class TechnicalDetails extends ConsumerWidget { return Column( children: [ SheetTile( - title: 'details'.t(context: context), + title: context.t.details, titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), _buildFileInfoTile(context, ref, asset, exifInfo), @@ -72,7 +72,7 @@ class TechnicalDetails extends ConsumerWidget { final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary); if (asset is LocalAsset) { - final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); + final assetMediaRepository = ref.read(assetMediaRepositoryProvider); return FutureBuilder( future: assetMediaRepository.getOriginalFilename(asset.id), builder: (context, snapshot) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 7233356402..9c17d9a035 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -333,9 +333,15 @@ class _AssetPageState extends ConsumerState { required bool isCurrent, required bool isPlayingMotionVideo, required String? localFilePath, + required Size? remoteThumbnailSize, }) { final size = context.sizeData; - final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath); + final imageProvider = getFullImageProvider( + asset, + size: size, + localFilePath: localFilePath, + remoteThumbnailSize: remoteThumbnailSize, + ); if (asset.isImage && !isPlayingMotionVideo) { return PhotoView( @@ -357,7 +363,7 @@ class _AssetPageState extends ConsumerState { onDragCancel: _onDragCancel, onTapUp: _onTapUp, onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, - errorBuilder: (_, __, ___) => SizedBox( + errorBuilder: (_, _, _) => SizedBox( width: size.width, height: size.height, child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), @@ -397,11 +403,13 @@ class _AssetPageState extends ConsumerState { @override Widget build(BuildContext context) { - final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); + final (currentAsset, thumbnailSize) = ref.watch( + assetViewerProvider.select((s) => (s.currentAsset, s.thumbnailSize)), + ); _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); - final timelineOrigin = ref.read(timelineServiceProvider).origin; + final timelineOrigin = ref.watch(timelineServiceProvider).origin; final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr)); final asset = _asset; @@ -454,6 +462,7 @@ class _AssetPageState extends ConsumerState { isCurrent: isCurrent, isPlayingMotionVideo: isPlayingMotionVideo, localFilePath: viewIntentFilePath, + remoteThumbnailSize: thumbnailSize, ), ), if (showingOcr && displayAsset.width != null && displayAsset.height != null) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart index 4d1856b90d..8c153e9995 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart @@ -17,7 +17,8 @@ class AssetPreloader { AssetPreloader({required this.timelineService, required this.mounted}); - void preload(int index, Size size) { + /// Preloads adjacent images with the current thumbnail size. + void preload(int index, Size size, {Size? thumbnailSize}) { unawaited(timelineService.preloadAssets(index)); _timer?.cancel(); _timer = Timer(Durations.medium4, () async { @@ -33,13 +34,14 @@ class AssetPreloader { } _prevStream?.removeListener(_dummyListener); _nextStream?.removeListener(_dummyListener); - _prevStream = prev != null ? _resolveImage(prev, size) : null; - _nextStream = next != null ? _resolveImage(next, size) : null; + _prevStream = prev != null ? _resolveImage(prev, size, thumbnailSize) : null; + _nextStream = next != null ? _resolveImage(next, size, thumbnailSize) : null; }); } - ImageStream _resolveImage(BaseAsset asset, Size size) { - return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener); + ImageStream _resolveImage(BaseAsset asset, Size size, Size? thumbnailSize) { + return getFullImageProvider(asset, size: size, remoteThumbnailSize: thumbnailSize).resolve(ImageConfiguration.empty) + ..addListener(_dummyListener); } void dispose() { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 23c473e618..77832c2685 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -17,11 +17,16 @@ class AssetStackRow extends ConsumerWidget { return const SizedBox.shrink(); } - final hideAssetStack = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; + final hideAssetStack = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash; if (hideAssetStack) { return const SizedBox.shrink(); } + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { + return const SizedBox.shrink(); + } + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 23cc6119fb..ad330309ab 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -14,6 +13,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; @@ -65,7 +65,8 @@ class AssetViewer extends ConsumerStatefulWidget { @override ConsumerState createState() => _AssetViewerState(); - static void setAsset(WidgetRef ref, BaseAsset asset) { + /// Sets the asset and thumbnail size before opening the viewer. + static void setAsset(WidgetRef ref, BaseAsset asset, {Size? thumbnailSize}) { ref.read(assetViewerProvider.notifier).reset(); // Hide controls by default for videos @@ -73,11 +74,7 @@ class AssetViewer extends ConsumerStatefulWidget { ref.read(assetViewerProvider.notifier).setControls(false); } - _setAsset(ref, asset); - } - - static void _setAsset(WidgetRef ref, BaseAsset asset) { - ref.read(assetViewerProvider.notifier).setAsset(asset); + ref.read(assetViewerProvider.notifier).setAsset(asset, thumbnailSize: thumbnailSize); } } @@ -163,7 +160,11 @@ class _AssetViewerState extends ConsumerState { } void _onAssetInit(Duration timeStamp) { - _preloader.preload(widget.initialIndex, context.sizeData); + _preloader.preload( + widget.initialIndex, + context.sizeData, + thumbnailSize: ref.read(assetViewerProvider).thumbnailSize, + ); _handleCasting(); } @@ -181,8 +182,8 @@ class _AssetViewerState extends ConsumerState { return; } - AssetViewer._setAsset(ref, asset); - _preloader.preload(index, context.sizeData); + ref.read(assetViewerProvider.notifier).setAsset(asset); + _preloader.preload(index, context.sizeData, thumbnailSize: ref.read(assetViewerProvider).thumbnailSize); _handleCasting(); _stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); @@ -209,7 +210,7 @@ class _AssetViewerState extends ConsumerState { SnackBar( duration: const Duration(seconds: 2), content: Text( - "local_asset_cast_failed".tr(), + context.t.local_asset_cast_failed, style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 3ed2b3a576..15b90e58ea 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -22,7 +22,9 @@ import 'package:immich_ui/immich_ui.dart'; List _actionColumnButtons(BuildContext context, WidgetRef ref, List actions) => actions .map((a) => a.create(context, ref)) .nonNulls - .map((item) => ImmichColumnButton(icon: item.icon, label: item.label, onPressed: item.onAction)) + .map( + (item) => .new(icon: item.icon, label: item.label, onPressed: item.onAction, onLongPress: item.onSecondaryAction), + ) .toList(growable: false); class ViewerBottomBar extends ConsumerWidget { @@ -38,7 +40,7 @@ class ViewerBottomBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); - final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; + final isInTrash = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash; final originalTheme = context.themeData; @@ -51,7 +53,7 @@ class ViewerBottomBar extends ConsumerWidget { UploadAction(source: .viewer, showProgress: true), EditAssetAction(source: .viewer), ]), - if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), + if (asset.hasRemote) ImmichColorOverride(color: null, child: AddActionButton(originalTheme: originalTheme)), ], ..._actionColumnButtons(context, ref, const [DeleteAction(source: .viewer)]), ], @@ -92,9 +94,12 @@ class ViewerBottomBar extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (asset.isImage) OcrToggleButton(asset: asset), - if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), + if (asset.isVideo) VideoControls(videoPlayerName: asset.id), if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ImmichColorOverride( + color: Colors.white, + child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ), ], ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart index 800af23039..78f234d39b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; @@ -30,7 +30,7 @@ class MotionPhotoPlayButton extends ConsumerWidget { child: Center( child: _MotionButton( isPlaying: isPlaying, - onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, + onPressed: ref.watch(isPlayingMotionVideoProvider.notifier).toggle, ), ), ), @@ -66,7 +66,7 @@ class _MotionButton extends StatelessWidget { ), const SizedBox(width: 8), Text( - CurrentPlatform.isAndroid ? 'motion'.t(context: context) : 'live'.t(context: context), + CurrentPlatform.isAndroid ? context.t.motion : context.t.live, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), ), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart index 576d4937b6..b86d7298d5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart @@ -110,7 +110,7 @@ class _OcrOverlayState extends ConsumerState { ); }, loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart index 73c7b2d3e7..cdff727967 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart @@ -28,7 +28,7 @@ class OcrToggleButton extends ConsumerWidget { shape: const CircleBorder(), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: ref.read(assetViewerProvider.notifier).toggleOcr, + onTap: ref.watch(assetViewerProvider.notifier).toggleOcr, child: const Padding( padding: EdgeInsets.all(10.0), child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white), diff --git a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart index 0ed37869be..fb5cfff255 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class RatingBar extends StatefulWidget { final double initialRating; @@ -129,10 +129,7 @@ class _RatingBarState extends State { }); widget.onClearRating?.call(); }, - child: Text( - 'rating_clear'.t(context: context), - style: TextStyle(color: context.themeData.colorScheme.primary), - ), + child: Text(context.t.rating_clear, style: TextStyle(color: context.themeData.colorScheme.primary)), ), ), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/sheet_tile.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/sheet_tile.widget.dart index ac6f79a155..8ff0befd7b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/sheet_tile.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/sheet_tile.widget.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -29,11 +29,7 @@ class SheetTile extends ConsumerWidget { void copyTitle(BuildContext context, WidgetRef ref) { unawaited(Clipboard.setData(ClipboardData(text: title))); - ImmichToast.show( - context: context, - msg: 'copied_to_clipboard'.t(context: context), - toastType: ToastType.info, - ); + ImmichToast.show(context: context, msg: context.t.copied_to_clipboard, toastType: ToastType.info); ref.read(hapticFeedbackProvider.notifier).selectionClick(); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 63b5663d9d..9011888b54 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -47,7 +47,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg bool _isVideoReady = false; bool _shouldPlayOnForeground = true; - VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier); + VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.id).notifier); @override void initState() { @@ -150,6 +150,10 @@ class _NativeVideoViewerState extends ConsumerState with Widg final remoteAsset = videoAsset as RemoteAsset; final serverEndpoint = Store.get(StoreKey.serverEndpoint); + if (!context.mounted) { + return null; + } + final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id; @@ -300,7 +304,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg @override Widget build(BuildContext context) { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); - final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status)); + final status = ref.watch(videoPlayerProvider(widget.asset.id).select((v) => v.status)); return IgnorePointer( child: Stack( diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index da0abad1dd..cbbc851108 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -29,7 +29,7 @@ class ViewerKebabMenu extends ConsumerWidget { final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); - final timelineOrigin = ref.read(timelineServiceProvider).origin; + final timelineOrigin = ref.watch(timelineServiceProvider).origin; final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final isInLockedView = ref.watch(inLockedViewProvider); final currentAlbum = ref.watch(currentRemoteAlbumProvider); diff --git a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart index b424298f5b..383ec17952 100644 --- a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart +++ b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; @@ -135,7 +135,7 @@ class BackupToggleButtonState extends ConsumerState with Sin children: [ Flexible( child: Text( - "enable_backup".t(context: context), + context.t.enable_backup, style: context.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: context.primaryColor, @@ -148,7 +148,7 @@ class BackupToggleButtonState extends ConsumerState with Sin Padding( padding: const EdgeInsets.only(top: 2), child: Text( - "upload_error_with_count".t(context: context, args: {'count': '$errorCount'}), + context.t.upload_error_with_count(count: errorCount), style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error), ), ), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 3e5edaa149..c68ce8d9ca 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -1,10 +1,11 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; @@ -51,15 +52,15 @@ class _ArchiveBottomSheetState extends ConsumerState { } if (!result.success) { - ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error); + ImmichToast.show(context: context, msg: context.t.scaffold_body_error_occurred, toastType: ToastType.error); return; } ImmichToast.show( context: context, msg: result.count == 0 - ? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}) - : 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ? context.t.add_to_album_bottom_sheet_already_exists(album: album.name) + : context.t.add_to_album_bottom_sheet_added(album: album.name), ); } @@ -73,6 +74,7 @@ class _ArchiveBottomSheetState extends ConsumerState { maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: const [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: ShareLinkAction(source: .timeline)), .new(action: ArchiveAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 7540da4fff..5ac6869c57 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; @@ -42,28 +43,25 @@ class FavoriteBottomSheet extends ConsumerWidget { } if (selectedAssets.length != remoteAssets.length) { - ImmichToast.show( - context: context, - msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context), - ); + ImmichToast.show(context: context, msg: context.t.add_to_album_bottom_sheet_some_local_assets); } // Only report the failure when nothing was added; if some succeeded we show "added". if (result.added > 0) { ImmichToast.show( context: context, - msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}), + msg: context.t.add_to_album_bottom_sheet_added(album: album.name), ); } else if (result.failed > 0) { ImmichToast.show( context: context, - msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failed}), + msg: context.t.assets_cannot_be_added_to_album_count(count: result.failed), toastType: ToastType.error, ); } else { ImmichToast.show( context: context, - msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}), + msg: context.t.add_to_album_bottom_sheet_already_exists(album: album.name), ); } @@ -75,6 +73,7 @@ class FavoriteBottomSheet extends ConsumerWidget { maxChildSize: 0.7, shouldCloseOnMinExtent: false, actions: const [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: ShareLinkAction(source: .timeline)), .new(action: FavoriteAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index d5407e768f..1fb78703a1 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -1,8 +1,8 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; @@ -54,14 +54,14 @@ class _GeneralBottomSheetState extends ConsumerState { } if (!result.success) { - ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error); + ImmichToast.show(context: context, msg: context.t.scaffold_body_error_occurred, toastType: ToastType.error); return; } ImmichToast.show( context: context, msg: result.count == 0 - ? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}) - : 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ? context.t.add_to_album_bottom_sheet_already_exists(album: album.name) + : context.t.add_to_album_bottom_sheet_added(album: album.name), ); } diff --git a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart index 478b37212e..74d5ec96da 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart @@ -1,9 +1,10 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/actions/upload.action.dart'; @@ -44,15 +45,15 @@ class _LocalAlbumBottomSheetState extends ConsumerState { } if (!result.success) { - ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error); + ImmichToast.show(context: context, msg: context.t.scaffold_body_error_occurred, toastType: ToastType.error); return; } ImmichToast.show( context: context, msg: result.count == 0 - ? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}) - : 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ? context.t.add_to_album_bottom_sheet_already_exists(album: album.name) + : context.t.add_to_album_bottom_sheet_added(album: album.name), ); } @@ -66,6 +67,7 @@ class _LocalAlbumBottomSheetState extends ConsumerState { maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: const [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: DeleteAction(source: .timeline)), .new(action: CleanupLocalAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart index d7334337ae..32730b8ac4 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart @@ -1,22 +1,23 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/lock.action.dart'; import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -class LockedFolderBottomSheet extends ConsumerWidget { +class LockedFolderBottomSheet extends StatelessWidget { const LockedFolderBottomSheet({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return const BaseBottomSheet( initialChildSize: 0.25, maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: DownloadAction(source: .timeline)), .new(action: DeleteAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart index 8a20f26c73..0c63cde140 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart @@ -1,9 +1,10 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; @@ -43,15 +44,15 @@ class _PartnerDetailBottomSheetState extends ConsumerState[ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: DownloadAction(source: .timeline)), ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 1e51a26811..9500e6329d 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; @@ -57,19 +58,15 @@ class _RemoteAlbumBottomSheetState extends ConsumerState } if (!result.success) { - ImmichToast.show( - context: context, - msg: 'scaffold_body_error_occurred'.t(context: context), - toastType: ToastType.error, - ); + ImmichToast.show(context: context, msg: context.t.scaffold_body_error_occurred, toastType: ToastType.error); return; } ImmichToast.show( context: context, msg: result.count == 0 - ? 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name}) - : 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}), + ? context.t.add_to_album_bottom_sheet_already_exists(album: album.name) + : context.t.add_to_album_bottom_sheet_added(album: album.name), ); } @@ -84,6 +81,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: [ + const .new(action: AssetDebugAction(source: .timeline)), const .new(action: ShareAction(source: .timeline)), const .new(action: ShareLinkAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart index 1d378f1350..32e5b01a99 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/restore.action.dart'; -class TrashBottomBar extends ConsumerWidget { +class TrashBottomBar extends StatelessWidget { const TrashBottomBar({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Align( alignment: Alignment.bottomCenter, child: Container( @@ -20,6 +20,7 @@ class TrashBottomBar extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: DeleteAction(source: .timeline)), .new(action: RestoreAction(source: .timeline)), ], diff --git a/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart b/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart index d748452c0e..dc89c10294 100644 --- a/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart +++ b/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:math' as math; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/feature_message.model.dart'; @@ -18,8 +17,8 @@ Future showFeatureMessageDialog(BuildContext context) { barrierLabel: context.t.whats_new, barrierColor: Colors.black.withValues(alpha: 0.55), transitionDuration: const Duration(milliseconds: 280), - pageBuilder: (_, __, ___) => const _FeatureMessageDialog(), - transitionBuilder: (_, animation, __, child) { + pageBuilder: (_, _, _) => const _FeatureMessageDialog(), + transitionBuilder: (_, animation, _, child) { final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic); return FadeTransition( opacity: animation, @@ -250,7 +249,7 @@ class _FeaturePage extends StatelessWidget { : Image.asset( highlight.image!, fit: BoxFit.contain, - errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(), + errorBuilder: (context, _, _) => const FeatureMessagePlaceholder(), ), ), ), @@ -263,12 +262,12 @@ class _FeaturePage extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - highlight.titleKey.tr(), + highlight.title(context.t), style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, fontSize: 24), ), const SizedBox(height: 8), Text( - highlight.bodyKey.tr(), + highlight.body(context.t), style: context.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant, height: 1.4), ), ], diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 927734ca25..bdedd29d45 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -155,6 +155,7 @@ ImageProvider getFullImageProvider( Size size = const Size(1080, 1920), bool edited = true, String? localFilePath, + Size? remoteThumbnailSize, }) { // Create new provider and cache it final ImageProvider provider; @@ -162,7 +163,15 @@ ImageProvider getFullImageProvider( provider = FileImage(File(localFilePath)); } else if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); + provider = LocalFullImageProvider( + id: id, + size: size, + assetType: asset.type, + isAnimated: asset.isAnimatedImage, + width: asset.width, + height: asset.height, + checksum: asset.checksum, + ); } else { final String assetId; final String thumbhash; @@ -181,21 +190,31 @@ ImageProvider getFullImageProvider( assetType: asset.type, isAnimated: asset.isAnimatedImage, edited: edited, + thumbnailSize: remoteThumbnailSize, ); } return provider; } -ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) { +ImageProvider? getThumbnailImageProvider( + BaseAsset asset, { + Size size = kThumbnailResolution, + + /// Physical size to decode for remote thumbnails, or null for the source size. + Size? remoteSize, + bool edited = true, +}) { if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - return LocalThumbProvider(id: id, size: size, assetType: asset.type); + return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: asset.checksum); } final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : ""; - return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null; + return assetId != null + ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited, decodeSize: remoteSize) + : null; } bool _shouldUseLocalAsset(BaseAsset asset) => diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index eba4f0a1cd..46e8eda85b 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -8,13 +10,19 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +// iOS GPU textures max out at 16384px; larger images squish. +const _kMaxPixelSize = 16384; + class LocalThumbProvider extends CancellableImageProvider with CancellableImageProviderMixin { final String id; final Size size; final AssetType assetType; - LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution}); + // an edit on the device keeps the id and changes the bytes, so the checksum is what separates two renders + final String? checksum; + + LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution}); @override Future obtainKey(ImageConfiguration configuration) { @@ -44,13 +52,13 @@ class LocalThumbProvider extends CancellableImageProvider return true; } if (other is LocalThumbProvider) { - return id == other.id; + return id == other.id && checksum == other.checksum; } return false; } @override - int get hashCode => id.hashCode; + int get hashCode => Object.hash(id, checksum); } class LocalFullImageProvider extends CancellableImageProvider @@ -59,8 +67,38 @@ class LocalFullImageProvider extends CancellableImageProvider + previewTargetSize(size.width * dpr, size.height * dpr, width, height, previewIsFinal: previewIsFinal); + + // Use an aspect-correct target when aspectFill would exceed the texture limit. + @visibleForTesting + static Size previewTargetSize(double boxW, double boxH, int? width, int? height, {required bool previewIsFinal}) { + if (width == null || height == null || width <= 0 || height <= 0) { + return Size(boxW, boxH); + } + final imgLong = math.max(width, height).toDouble(); + final coverLong = imgLong * math.max(boxW / width, boxH / height); + if (coverLong <= _kMaxPixelSize) { + return Size(boxW, boxH); + } + final bound = previewIsFinal ? _kMaxPixelSize.toDouble() : math.max(boxW, boxH); + final scale = math.min(1.0, bound / imgLong); + return Size(math.max(1.0, width * scale), math.max(1.0, height * scale)); + } @override Future obtainKey(ImageConfiguration configuration) { @@ -73,7 +111,7 @@ class LocalFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), @@ -86,7 +124,7 @@ class LocalFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), @@ -108,7 +146,7 @@ class LocalFullImageProvider extends CancellableImageProvider id.hashCode ^ size.hashCode ^ isAnimated.hashCode; + int get hashCode => Object.hash(id, size, isAnimated, width, height, checksum); } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 9618ee72ad..b533fd8cf5 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -14,10 +14,17 @@ class RemoteImageProvider extends CancellableImageProvider final String url; final bool edited; - RemoteImageProvider({required this.url, this.edited = true}); + /// Physical size to decode, or null for the source size. + final Size? decodeSize; - RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, this.edited = true}) - : url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited); + RemoteImageProvider({required this.url, this.edited = true, this.decodeSize}); + + RemoteImageProvider.thumbnail({ + required String assetId, + required String thumbhash, + this.edited = true, + this.decodeSize, + }) : url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited); @override Future obtainKey(ImageConfiguration configuration) { @@ -37,7 +44,7 @@ class RemoteImageProvider extends CancellableImageProvider } Stream _codec(RemoteImageProvider key, ImageDecoderCallback decode) { - final request = this.request = RemoteImageRequest(uri: key.url); + final request = this.request = RemoteImageRequest(uri: key.url, decodeSize: key.decodeSize); return loadRequest(request, decode, isFinal: true); } @@ -47,13 +54,13 @@ class RemoteImageProvider extends CancellableImageProvider return true; } if (other is RemoteImageProvider) { - return url == other.url && edited == other.edited; + return url == other.url && edited == other.edited && decodeSize == other.decodeSize; } return false; } @override - int get hashCode => url.hashCode ^ edited.hashCode; + int get hashCode => url.hashCode ^ edited.hashCode ^ decodeSize.hashCode; } class RemoteFullImageProvider extends CancellableImageProvider @@ -64,12 +71,16 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), @@ -96,7 +109,12 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 90bb79cced..5bfeb25977 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -26,17 +26,22 @@ class Thumbnail extends StatefulWidget { required String remoteId, required String thumbhash, this.fit = BoxFit.cover, - Size size = kThumbnailResolution, + + /// Physical size to decode, or null for the source size. + Size? decodeSize, super.key, - }) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash), + }) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash, decodeSize: decodeSize), thumbhashProvider = null; Thumbnail.fromAsset({ required BaseAsset? asset, this.fit = BoxFit.cover, - /// The logical UI size of the thumbnail. This is only used to determine the ideal image resolution and does not affect the widget size. + /// Decode size for local thumbnails. This does not affect the widget size. Size size = kThumbnailResolution, + + /// Physical size to decode for remote thumbnails. + Size? remoteSize, super.key, }) : thumbhashProvider = switch (asset) { RemoteAsset() when asset.thumbHash != null && asset.localId == null => ThumbHashProvider( @@ -44,7 +49,7 @@ class Thumbnail extends StatefulWidget { ), _ => null, }, - imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size); + imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size, remoteSize: remoteSize); @override State createState() => _ThumbnailState(); diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 1d3dcb2cf0..953efc144d 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -16,6 +16,7 @@ class ThumbnailTile extends ConsumerStatefulWidget { const ThumbnailTile( this.asset, { this.size = kThumbnailResolution, + this.remoteSize, this.fit = BoxFit.cover, this.showStorageIndicator = false, this.lockSelection = false, @@ -26,6 +27,9 @@ class ThumbnailTile extends ConsumerStatefulWidget { final BaseAsset? asset; final Size size; + + /// Physical size to decode for remote thumbnails. + final Size? remoteSize; final BoxFit fit; final bool showStorageIndicator; final bool lockSelection; @@ -108,7 +112,7 @@ class _ThumbnailTileState extends ConsumerState { // but other solutions have failed thus far. key: ValueKey(isCurrentAsset), tag: '${asset?.heroTag}_$heroIndex', - child: Thumbnail.fromAsset(asset: asset, size: widget.size), + child: Thumbnail.fromAsset(asset: asset, size: widget.size, remoteSize: widget.remoteSize), // Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree. placeholderBuilder: (context, heroSize, child) { if (!_hideIndicators) { diff --git a/mobile/lib/presentation/widgets/map/map.widget.dart b/mobile/lib/presentation/widgets/map/map.widget.dart index 918d64054b..ab2db23d8b 100644 --- a/mobile/lib/presentation/widgets/map/map.widget.dart +++ b/mobile/lib/presentation/widgets/map/map.widget.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; @@ -164,7 +164,7 @@ class _DriftMapState extends ConsumerState { context: context, gravity: ToastGravity.BOTTOM, toastType: ToastType.error, - msg: "map_cannot_get_user_location".t(context: context), + msg: context.t.map_cannot_get_user_location, ); } return; diff --git a/mobile/lib/presentation/widgets/map/map_settings_sheet.dart b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart index 2680850bfe..c2a5678ee9 100644 --- a/mobile/lib/presentation/widgets/map/map_settings_sheet.dart +++ b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/time_range.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart'; @@ -50,18 +49,18 @@ class _DriftMapSettingsSheetState extends ConsumerState { ), const Divider(height: 30, thickness: 1), MapSettingsListTile( - title: "map_settings_only_show_favorites".t(context: context), + title: context.t.map_settings_only_show_favorites, selected: mapState.onlyFavorites, onChanged: (favoriteOnly) => ref.read(mapStateProvider.notifier).switchFavoriteOnly(favoriteOnly), ), MapSettingsListTile( - title: "map_settings_include_show_archived".t(context: context), + title: context.t.map_settings_include_show_archived, selected: mapState.includeArchived, onChanged: (includeArchive) => ref.read(mapStateProvider.notifier).switchIncludeArchived(includeArchive), ), MapSettingsListTile( - title: "map_settings_include_show_partners".t(context: context), + title: context.t.map_settings_include_show_partners, selected: mapState.withPartners, onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners), ), diff --git a/mobile/lib/presentation/widgets/map/map_utils.dart b/mobile/lib/presentation/widgets/map/map_utils.dart index 34116b144b..3ed6ceee25 100644 --- a/mobile/lib/presentation/widgets/map/map_utils.dart +++ b/mobile/lib/presentation/widgets/map/map_utils.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -124,10 +124,10 @@ class MapUtils { class _LocationServiceDisabledDialog extends ConfirmDialog { _LocationServiceDisabledDialog(BuildContext context) : super( - title: 'map_location_service_disabled_title'.t(context: context), - content: 'map_location_service_disabled_content'.t(context: context), - cancel: 'cancel'.t(context: context), - ok: 'yes'.t(context: context), + title: context.t.map_location_service_disabled_title, + content: context.t.map_location_service_disabled_content, + cancel: context.t.cancel, + ok: context.t.yes, onOk: () async { await Geolocator.openLocationSettings(); }, @@ -137,10 +137,10 @@ class _LocationServiceDisabledDialog extends ConfirmDialog { class _LocationPermissionDisabledDialog extends ConfirmDialog { _LocationPermissionDisabledDialog(BuildContext context) : super( - title: 'map_no_location_permission_title'.t(context: context), - content: 'map_no_location_permission_content'.t(context: context), - cancel: 'cancel'.t(context: context), - ok: 'yes'.t(context: context), + title: context.t.map_no_location_permission_title, + content: context.t.map_no_location_permission_content, + cancel: context.t.cancel, + ok: context.t.yes, onOk: () {}, ); } diff --git a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart index fc9bfb34e7..eb8ce1815a 100644 --- a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart @@ -1,12 +1,11 @@ -// ignore_for_file: require_trailing_commas - import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:intl/intl.dart'; class DriftMemoryBottomInfo extends StatelessWidget { final DriftMemory memory; @@ -36,7 +35,7 @@ class DriftMemoryBottomInfo extends StatelessWidget { ], ), Tooltip( - message: 'view_in_timeline'.tr(), + message: context.t.view_in_timeline, child: MaterialButton( minWidth: 0, onPressed: () async { diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index b0b7816889..1732249523 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -4,7 +4,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; @@ -45,15 +45,15 @@ class DriftMemoryLane extends ConsumerWidget { } } -class DriftMemoryCard extends ConsumerWidget { +class DriftMemoryCard extends StatelessWidget { const DriftMemoryCard({super.key, required this.memory}); final DriftMemory memory; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final yearsAgo = DateTime.now().year - memory.data.year; - final title = 'years_ago'.t(context: context, args: {'years': yearsAgo.toString()}); + final title = context.t.years_ago(years: yearsAgo); return Center( child: Stack( children: [ diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index b5a9dd235c..e56a21e021 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -4,14 +4,14 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:scroll_date_picker/scroll_date_picker.dart'; class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonBirthdayEditForm({super.key, required this.person}); @@ -30,14 +30,9 @@ class _DriftPersonNameEditFormState extends ConsumerState saveBirthday() async { try { - final result = await ref.read(driftPeopleServiceProvider).updateBrithday(widget.person.id, _selectedDate); - - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } + final result = await ref.read(driftPeopleServiceProvider).updateBirthday(widget.person.id, _selectedDate); + if (result != 0 && mounted) { context.pop(_selectedDate); } } catch (error) { @@ -49,7 +44,7 @@ class _DriftPersonNameEditFormState extends ConsumerState context.pop(null), child: Text( - "cancel", + context.t.cancel, style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold), - ).tr(), + ), ), TextButton( onPressed: () => saveBirthday(), child: Text( - "save", + context.t.save, style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold), - ).tr(), + ), ), ], ); diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart index bb63de3695..4ae41bc640 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart @@ -1,16 +1,15 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class DriftPersonNameEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonNameEditForm({super.key, required this.person}); @@ -30,12 +29,7 @@ class _DriftPersonNameEditFormState extends ConsumerState onEdit(String personId, String newName) async { try { final result = await ref.read(driftPeopleServiceProvider).updateName(personId, newName); - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } - + if (result != 0 && mounted) { context.pop(newName); } } catch (error) { @@ -47,7 +41,7 @@ class _DriftPersonNameEditFormState extends ConsumerState context.pop(null), child: Text( - "cancel", + context.t.cancel, style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold), - ).tr(), + ), ), TextButton( onPressed: () => onEdit(widget.person.id, _formController.text), child: Text( - "save", + context.t.save, style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold), - ).tr(), + ), ), ], ); diff --git a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart index b0ded02624..026a28bd7d 100644 --- a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; -class PersonOptionSheet extends ConsumerWidget { +class PersonOptionSheet extends StatelessWidget { const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday, this.birthdayExists = false}); final VoidCallback? onEditName; @@ -10,7 +9,7 @@ class PersonOptionSheet extends ConsumerWidget { final bool birthdayExists; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600); return SafeArea( @@ -21,12 +20,12 @@ class PersonOptionSheet extends ConsumerWidget { children: [ ListTile( leading: const Icon(Icons.edit), - title: Text('edit_name'.t(context: context), style: textStyle), + title: Text(context.t.edit_name, style: textStyle), onTap: onEditName, ), ListTile( leading: const Icon(Icons.cake), - title: Text((birthdayExists ? 'edit_birthday' : "add_birthday").t(context: context), style: textStyle), + title: Text(birthdayExists ? context.t.edit_birthday : context.t.add_birthday, style: textStyle), onTap: onEditBirthday, ), ], diff --git a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart index 355e1a01a8..dc89a25788 100644 --- a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart +++ b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -class DriftRemoteAlbumOption extends ConsumerWidget { +class DriftRemoteAlbumOption extends StatelessWidget { const DriftRemoteAlbumOption({ super.key, this.onAddPhotos, @@ -31,25 +30,20 @@ class DriftRemoteAlbumOption extends ConsumerWidget { final List? iconShadows; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final theme = context.themeData; final menuChildren = []; if (onEditAlbum != null) { menuChildren.add( - BaseActionButton( - label: 'edit_album'.t(context: context), - iconData: Icons.edit, - onPressed: onEditAlbum, - menuItem: true, - ), + BaseActionButton(label: context.t.edit_album, iconData: Icons.edit, onPressed: onEditAlbum, menuItem: true), ); } if (onAddPhotos != null) { menuChildren.add( BaseActionButton( - label: 'add_photos'.t(context: context), + label: context.t.add_photos, iconData: Icons.add_a_photo, onPressed: onAddPhotos, menuItem: true, @@ -60,7 +54,7 @@ class DriftRemoteAlbumOption extends ConsumerWidget { if (onAddUsers != null) { menuChildren.add( BaseActionButton( - label: 'album_viewer_page_share_add_users'.t(context: context), + label: context.t.album_viewer_page_share_add_users, iconData: Icons.group_add, onPressed: onAddUsers, menuItem: true, @@ -71,7 +65,7 @@ class DriftRemoteAlbumOption extends ConsumerWidget { if (onLeaveAlbum != null) { menuChildren.add( BaseActionButton( - label: 'leave_album'.t(context: context), + label: context.t.leave_album, iconData: Icons.person_remove_rounded, onPressed: onLeaveAlbum, menuItem: true, @@ -82,7 +76,7 @@ class DriftRemoteAlbumOption extends ConsumerWidget { if (onToggleAlbumOrder != null) { menuChildren.add( BaseActionButton( - label: 'change_display_order'.t(context: context), + label: context.t.change_display_order, iconData: Icons.swap_vert_rounded, onPressed: onToggleAlbumOrder, menuItem: true, @@ -93,7 +87,7 @@ class DriftRemoteAlbumOption extends ConsumerWidget { if (onCreateSharedLink != null) { menuChildren.add( BaseActionButton( - label: 'create_shared_link'.t(context: context), + label: context.t.create_shared_link, iconData: Icons.link, onPressed: onCreateSharedLink, menuItem: true, @@ -103,12 +97,7 @@ class DriftRemoteAlbumOption extends ConsumerWidget { if (onShowOptions != null) { menuChildren.add( - BaseActionButton( - label: 'options'.t(context: context), - iconData: Icons.settings, - onPressed: onShowOptions, - menuItem: true, - ), + BaseActionButton(label: context.t.options, iconData: Icons.settings, onPressed: onShowOptions, menuItem: true), ); } @@ -116,7 +105,7 @@ class DriftRemoteAlbumOption extends ConsumerWidget { menuChildren.add(const Divider(height: 1)); menuChildren.add( BaseActionButton( - label: 'delete_album'.t(context: context), + label: context.t.delete_album, iconData: Icons.delete, iconColor: context.isDarkTheme ? Colors.red[400] : Colors.red[800], onPressed: onDeleteAlbum, diff --git a/mobile/lib/presentation/widgets/search/quick_date_picker.dart b/mobile/lib/presentation/widgets/search/quick_date_picker.dart index e8bf3c5a43..97ea163be8 100644 --- a/mobile/lib/presentation/widgets/search/quick_date_picker.dart +++ b/mobile/lib/presentation/widgets/search/quick_date_picker.dart @@ -1,7 +1,7 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:intl/intl.dart'; sealed class DateFilterInputModel { DateTimeRange asDateTimeRange(); @@ -13,12 +13,9 @@ sealed class DateFilterInputModel { if (date.end.difference(date.start).inHours < 24) { return DateFormat.yMMMd().format(date.start.toLocal()); } else { - return 'search_filter_date_interval'.t( - context: context, - args: { - "start": DateFormat.yMMMd().format(date.start.toLocal()), - "end": DateFormat.yMMMd().format(date.end.toLocal()), - }, + return context.t.search_filter_date_interval( + start: DateFormat.yMMMd().format(date.start.toLocal()), + end: DateFormat.yMMMd().format(date.end.toLocal()), ); } } @@ -38,7 +35,7 @@ class RecentMonthRangeFilter extends DateFilterInputModel { @override String asHumanReadable(BuildContext context) { - return 'last_months'.t(context: context, args: {"count": monthDelta.toString()}); + return context.t.last_months(count: monthDelta); } } @@ -62,7 +59,7 @@ class YearFilter extends DateFilterInputModel { @override String asHumanReadable(BuildContext context) { - return 'in_year'.tr(namedArgs: {"year": year.toString()}); + return context.t.in_year(year: year); } } @@ -121,14 +118,13 @@ class QuickDatePicker extends HookWidget { return null; } - Text _monthLabel(BuildContext context, int monthsFromNow) => - const Text('last_months').t(context: context, args: {"count": monthsFromNow.toString()}); + Text _monthLabel(BuildContext context, int monthsFromNow) => Text(context.t.last_months(count: monthsFromNow)); Widget _yearPicker(BuildContext context) { final size = MediaQuery.of(context).size; return Row( children: [ - const Text("in_year_selector").tr(), + Text(context.t.in_year_selector), const SizedBox(width: 15), Expanded( child: DropdownMenu( @@ -158,7 +154,7 @@ class QuickDatePicker extends HookWidget { child: IgnorePointer( ignoring: true, child: RadioListTile( - title: const Text('pick_custom_range').tr(), + title: Text(context.t.pick_custom_range), subtitle: hasPreviousInput ? Text(currentInput!.asHumanReadable(context)) : null, secondary: hasPreviousInput ? const Icon(Icons.edit) : null, value: _QuickPickerType.custom, diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart index 84892c79f7..86c44d4a7f 100644 --- a/mobile/lib/presentation/widgets/timeline/constants.dart +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -9,5 +9,5 @@ const double kScrubberThumbHeight = 48.0; const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300); const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); -const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size +const Size kThumbnailResolution = Size.square(320); const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 16c947ca5e..32bbffa95e 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -107,7 +107,7 @@ class _FixedSegmentRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); - final timelineService = ref.read(timelineServiceProvider); + final timelineService = ref.watch(timelineServiceProvider); final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3); if (timelineService.hasRange(assetIndex, assetCount)) { @@ -144,19 +144,6 @@ class _FixedSegmentRow extends ConsumerWidget { TimelineService timelineService, bool isDynamicLayout, ) { - final children = [ - for (int i = 0; i < assets.length; i++) - TimelineAssetIndexWrapper( - assetIndex: assetIndex + i, - segmentIndex: 0, // For simplicity, using 0 for now - child: _AssetTileWidget( - key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), - asset: assets[i], - assetIndex: assetIndex + i, - ), - ), - ]; - final widths = List.filled(assets.length, tileHeight); if (isDynamicLayout) { @@ -186,6 +173,20 @@ class _FixedSegmentRow extends ConsumerWidget { } } + final children = [ + for (int i = 0; i < assets.length; i++) + TimelineAssetIndexWrapper( + assetIndex: assetIndex + i, + segmentIndex: 0, // For simplicity, using 0 for now + child: _AssetTileWidget( + key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), + asset: assets[i], + assetIndex: assetIndex + i, + size: Size(widths[i], tileHeight), + ), + ), + ]; + return TimelineDragRegion( child: TimelineRow( height: tileHeight, @@ -201,10 +202,18 @@ class _FixedSegmentRow extends ConsumerWidget { class _AssetTileWidget extends ConsumerWidget { final BaseAsset asset; final int assetIndex; + final Size size; - const _AssetTileWidget({super.key, required this.asset, required this.assetIndex}); + const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.size}); - Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async { + Future _handleOnTap( + BuildContext ctx, + WidgetRef ref, + int assetIndex, + BaseAsset asset, + int? heroOffset, + Size remoteSize, + ) async { final multiSelectState = ref.read(multiSelectProvider); if (multiSelectState.forceEnable || multiSelectState.isEnabled) { @@ -216,7 +225,7 @@ class _AssetTileWidget extends ConsumerWidget { } ref.read(isPlayingMotionVideoProvider.notifier).playing = false; - AssetViewer.setAsset(ref, asset); + AssetViewer.setAsset(ref, asset, thumbnailSize: remoteSize); unawaited( ctx.pushRoute( AssetViewerRoute( @@ -256,19 +265,22 @@ class _AssetTileWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final remoteSize = size * MediaQuery.devicePixelRatioOf(context); + final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0; final lockSelection = _getLockSelectionStatus(ref); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); - final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash; + final showStackIndicator = ref.watch(timelineServiceProvider).origin != TimelineOrigin.trash; return RepaintBoundary( child: GestureDetector( - onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset), + onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset, remoteSize), onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset), child: ThumbnailTile( asset, + remoteSize: remoteSize, lockSelection: lockSelection, showStorageIndicator: showStorageIndicator, showStackIndicator: showStackIndicator, diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart index 76176041fa..433118d13c 100644 --- a/mobile/lib/presentation/widgets/timeline/header.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; @@ -12,7 +13,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -class TimelineHeader extends HookConsumerWidget { +class TimelineHeader extends HookWidget { final Bucket bucket; final HeaderType header; final double height; @@ -39,7 +40,7 @@ class TimelineHeader extends HookConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { if (bucket is! TimeBucket || header == HeaderType.none) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index eb081a1e6a..a2298313ac 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -38,7 +38,6 @@ class Scrubber extends ConsumerStatefulWidget { Scrubber({ super.key, - Key? scrollThumbKey, required this.layoutSegments, required this.timelineHeight, this.topPadding = 0, diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index b39c431b43..adad06b987 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; @@ -8,66 +10,28 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -class TimelineArgs { - final double maxWidth; - final double maxHeight; - final double spacing; - final int columnCount; - final bool showStorageIndicator; - final bool withStack; - final GroupAssetsBy? groupBy; +part 'timeline.state.freezed.dart'; - const TimelineArgs({ - required this.maxWidth, - required this.maxHeight, - this.spacing = kTimelineSpacing, - this.columnCount = kTimelineColumnCount, - this.showStorageIndicator = false, - this.withStack = false, - this.groupBy, - }); - - @override - bool operator ==(covariant TimelineArgs other) { - return spacing == other.spacing && - maxWidth == other.maxWidth && - maxHeight == other.maxHeight && - columnCount == other.columnCount && - showStorageIndicator == other.showStorageIndicator && - withStack == other.withStack && - groupBy == other.groupBy; - } - - @override - int get hashCode => - maxWidth.hashCode ^ - maxHeight.hashCode ^ - spacing.hashCode ^ - columnCount.hashCode ^ - showStorageIndicator.hashCode ^ - withStack.hashCode ^ - groupBy.hashCode; +@freezed +abstract class TimelineArgs with _$TimelineArgs { + const factory TimelineArgs({ + required double maxWidth, + required double maxHeight, + @Default(kTimelineSpacing) double spacing, + @Default(kTimelineColumnCount) int columnCount, + @Default(false) bool showStorageIndicator, + @Default(false) bool withStack, + GroupAssetsBy? groupBy, + }) = _TimelineArgs; } -class TimelineState { - final bool isScrubbing; - final bool isScrolling; +@freezed +abstract class TimelineState with _$TimelineState { + const TimelineState._(); - const TimelineState({this.isScrubbing = false, this.isScrolling = false}); + const factory TimelineState({@Default(false) bool isScrubbing, @Default(false) bool isScrolling}) = _TimelineState; bool get isInteracting => isScrubbing || isScrolling; - - @override - bool operator ==(covariant TimelineState other) { - return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling; - } - - @override - int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode; - - TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) { - return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing, isScrolling: isScrolling ?? this.isScrolling); - } } class TimelineStateNotifier extends Notifier { diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 9e65dcb72c..0387cbf597 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; @@ -190,7 +191,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi // may be in a background tab. In either case, `handleStatusBarTap()` still fires // Make sure the timeline is the primary route before scrolling to the top final routeData = context.findAncestorWidgetOfExactType()?.routeData; - if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true) { + // The tap is generated async, so it can arrive after a route pop has started (due to a back button or similar) + // Check if route is alive and not exiting before taking action + final observers = Navigator.maybeOf(context)?.widget.observers ?? const []; + final isRouteTransitioning = observers.whereType().any( + (observer) => observer.hasTransitioningRoute, + ); + + if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true && !isRouteTransitioning) { _scrollToTop(); } } @@ -400,7 +408,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi return PopScope( canPop: !isMultiSelectEnabled, - onPopInvokedWithResult: (_, __) { + onPopInvokedWithResult: (_, _) { if (isMultiSelectEnabled) { ref.read(multiSelectProvider.notifier).reset(); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline_drag_region.dart b/mobile/lib/presentation/widgets/timeline/timeline_drag_region.dart index 9ffcc3b23b..892d0c6102 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline_drag_region.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline_drag_region.dart @@ -4,6 +4,9 @@ import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'timeline_drag_region.freezed.dart'; class TimelineDragRegion extends StatefulWidget { final Widget child; @@ -206,21 +209,7 @@ class _TimelineAssetIndexProxy extends RenderProxyBox { _TimelineAssetIndexProxy({required this.index}); } -class TimelineAssetIndex { - final int assetIndex; - final int segmentIndex; - - const TimelineAssetIndex({required this.assetIndex, required this.segmentIndex}); - - @override - bool operator ==(covariant TimelineAssetIndex other) { - if (identical(this, other)) { - return true; - } - - return other.assetIndex == assetIndex && other.segmentIndex == segmentIndex; - } - - @override - int get hashCode => assetIndex.hashCode ^ segmentIndex.hashCode; +@freezed +abstract class TimelineAssetIndex with _$TimelineAssetIndex { + const factory TimelineAssetIndex({required int assetIndex, required int segmentIndex}) = _TimelineAssetIndex; } diff --git a/mobile/lib/providers/album/album_sort_by_options.provider.dart b/mobile/lib/providers/album/album_sort_by_options.provider.dart index ec4ae71d03..015a846de8 100644 --- a/mobile/lib/providers/album/album_sort_by_options.provider.dart +++ b/mobile/lib/providers/album/album_sort_by_options.provider.dart @@ -1,19 +1,28 @@ import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; // Store index allows us to re-arrange the values without affecting the saved prefs enum AlbumSortMode { - title(1, "library_page_sort_title", SortOrder.asc), - assetCount(4, "library_page_sort_asset_count", SortOrder.desc), - lastModified(3, "library_page_sort_last_modified", SortOrder.desc), - created(0, "library_page_sort_created", SortOrder.desc), - mostRecent(2, "sort_recent", SortOrder.desc), - mostOldest(5, "sort_oldest", SortOrder.asc); + title(1, SortOrder.asc), + assetCount(4, SortOrder.desc), + lastModified(3, SortOrder.desc), + created(0, SortOrder.desc), + mostRecent(2, SortOrder.desc), + mostOldest(5, SortOrder.asc); final int storeIndex; - final String label; final SortOrder defaultOrder; - const AlbumSortMode(this.storeIndex, this.label, this.defaultOrder); + const AlbumSortMode(this.storeIndex, this.defaultOrder); + + String label(Translations t) => switch (this) { + AlbumSortMode.title => t.library_page_sort_title, + AlbumSortMode.assetCount => t.library_page_sort_asset_count, + AlbumSortMode.lastModified => t.library_page_sort_last_modified, + AlbumSortMode.created => t.library_page_sort_created, + AlbumSortMode.mostRecent => t.sort_recent, + AlbumSortMode.mostOldest => t.sort_oldest, + }; SortOrder effectiveOrder(bool isReverse) => isReverse ? defaultOrder.reverse() : defaultOrder; } diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 8678f7c32e..f369a37142 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -81,6 +81,10 @@ class AppLifeCycleNotifier extends StateNotifier { await _ref.read(serverInfoProvider.notifier).getServerVersion(); } + if (!_shouldContinueOperation()) { + _wasPaused = true; + return; + } _ref.read(websocketProvider.notifier).connect(); await _handleBetaTimelineResume(); @@ -89,13 +93,13 @@ class AppLifeCycleNotifier extends StateNotifier { await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus(); } - Future _safeRun(Future action, String debugName) async { + Future _safeRun(Future Function() action, String debugName) async { if (!_shouldContinueOperation()) { return; } try { - await action; + await action(); } catch (e, stackTrace) { _log.warning("Error during $debugName operation", e, stackTrace); } @@ -108,18 +112,26 @@ class AppLifeCycleNotifier extends StateNotifier { await Future.delayed(const Duration(milliseconds: 500)); final backgroundManager = _ref.read(backgroundSyncProvider); + + // Drop any sync that froze mid-flight while the app was suspended so resume + // starts fresh instead of awaiting the stale task (#28082). cancelResumeSyncs + // clears the task refs synchronously, so the syncs below see a clean slate. + unawaited(backgroundManager.cancelResumeSyncs()); + final isAlbumLinkedSyncEnable = _ref.read(appConfigProvider).backup.syncAlbums; try { bool syncSuccess = false; await Future.wait([ - _safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"), - _safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"), + _safeRun(() => backgroundManager.syncLocal(full: CurrentPlatform.isAndroid), "syncLocal"), + _safeRun(() async { + syncSuccess = await backgroundManager.syncRemote(); + }, "syncRemote"), ]); _ref.invalidate(driftMemoryFutureProvider); if (syncSuccess) { await Future.wait([ - _safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) { + _safeRun(backgroundManager.hashAssets, "hashAssets").then((_) { unawaited(_resumeBackup()); }), _resumeBackup(), @@ -127,11 +139,11 @@ class AppLifeCycleNotifier extends StateNotifier { // _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"), ]); } else { - await _safeRun(backgroundManager.hashAssets(), "hashAssets"); + await _safeRun(backgroundManager.hashAssets, "hashAssets"); } if (isAlbumLinkedSyncEnable) { - await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum"); + await _safeRun(backgroundManager.syncLinkedAlbum, "syncLinkedAlbum"); } } catch (e, stackTrace) { _log.severe("Error during background sync", e, stackTrace); @@ -145,7 +157,7 @@ class AppLifeCycleNotifier extends StateNotifier { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { await _safeRun( - _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id), + () => _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id), "handleBackupResume", ); } @@ -195,7 +207,7 @@ class AppLifeCycleNotifier extends StateNotifier { Future _performPause() { if (_ref.read(authProvider).isAuthenticated) { - _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); + _ref.read(driftBackupProvider.notifier).stopForegroundBackup(reason: "the app being sent to the background"); _ref.read(websocketProvider.notifier).disconnect(); } diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index 7b1d5d2caa..fd7fbebe36 100644 --- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -1,81 +1,29 @@ import 'dart:async'; +import 'dart:ui'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -class AssetViewerState { - final double backgroundOpacity; - final bool showingDetails; - final bool showingControls; - final bool isZoomed; - final bool showingOcr; - final BaseAsset? currentAsset; - final int stackIndex; +part 'asset_viewer.provider.freezed.dart'; - const AssetViewerState({ - this.backgroundOpacity = 1.0, - this.showingDetails = false, - this.showingControls = true, - this.isZoomed = false, - this.showingOcr = false, - this.currentAsset, - this.stackIndex = 0, - }); - - AssetViewerState copyWith({ - double? backgroundOpacity, - bool? showingDetails, - bool? showingControls, - bool? isZoomed, - bool? showingOcr, +@freezed +abstract class AssetViewerState with _$AssetViewerState { + const factory AssetViewerState({ + @Default(1.0) double backgroundOpacity, + @Default(false) bool showingDetails, + @Default(true) bool showingControls, + @Default(false) bool isZoomed, + @Default(false) bool showingOcr, BaseAsset? currentAsset, - int? stackIndex, - }) { - return AssetViewerState( - backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, - showingDetails: showingDetails ?? this.showingDetails, - showingControls: showingControls ?? this.showingControls, - isZoomed: isZoomed ?? this.isZoomed, - showingOcr: showingOcr ?? this.showingOcr, - currentAsset: currentAsset ?? this.currentAsset, - stackIndex: stackIndex ?? this.stackIndex, - ); - } - @override - String toString() { - return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed, showingOcr: $showingOcr)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - if (other.runtimeType != runtimeType) { - return false; - } - return other is AssetViewerState && - other.backgroundOpacity == backgroundOpacity && - other.showingDetails == showingDetails && - other.showingControls == showingControls && - other.isZoomed == isZoomed && - other.showingOcr == showingOcr && - other.currentAsset == currentAsset && - other.stackIndex == stackIndex; - } - - @override - int get hashCode => - backgroundOpacity.hashCode ^ - showingDetails.hashCode ^ - showingControls.hashCode ^ - isZoomed.hashCode ^ - showingOcr.hashCode ^ - currentAsset.hashCode ^ - stackIndex.hashCode; + /// Physical thumbnail size retained while paging through the viewer. + Size? thumbnailSize, + @Default(0) int stackIndex, + }) = _AssetViewerState; } class AssetViewerStateNotifier extends Notifier { @@ -93,11 +41,17 @@ class AssetViewerStateNotifier extends Notifier { state = const AssetViewerState(); } - void setAsset(BaseAsset asset) { + void setAsset(BaseAsset asset, {Size? thumbnailSize}) { if (asset == state.currentAsset) { return; } - state = state.copyWith(currentAsset: asset, stackIndex: 0, showingOcr: false); + // Swiping to a neighbor passes no size; keep the tapped tile's so neighbors reuse it. + state = state.copyWith( + currentAsset: asset, + thumbnailSize: thumbnailSize ?? state.thumbnailSize, + stackIndex: 0, + showingOcr: false, + ); _watchCurrentAsset(asset); } @@ -123,9 +77,9 @@ class AssetViewerStateNotifier extends Notifier { } state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); - final heroTag = state.currentAsset?.heroTag; - if (heroTag != null) { - final notifier = ref.read(videoPlayerProvider(heroTag).notifier); + final id = state.currentAsset?.id; + if (id != null) { + final notifier = ref.read(videoPlayerProvider(id).notifier); showing ? notifier.hold() : notifier.release(); } } diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 2c4854bdb0..ba72ef60c5 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -14,9 +14,29 @@ class DownloadStateNotifier extends StateNotifier { taskProgress: {}, ), ) { + _downloadService.onImageDownloadStatus = _downloadStatusCallback; + _downloadService.onVideoDownloadStatus = _downloadStatusCallback; + _downloadService.onLivePhotoDownloadStatus = _downloadStatusCallback; _downloadService.onTaskProgress = _taskProgressCallback; } + void _downloadStatusCallback(TaskStatusUpdate update) { + if (update.status == TaskStatus.canceled) { + return; + } + + final existing = state.taskProgress[update.task.taskId]; + if (existing == null) { + return; + } + + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({update.task.taskId: existing.copyWith(status: update.status)}), + ); + } + void _taskProgressCallback(TaskProgressUpdate update) { // Ignore if the task is canceled or completed if (update.progress == -2 || update.progress == -1) { diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart index 37b3145eb4..5873e992b1 100644 --- a/mobile/lib/providers/background_sync.provider.dart +++ b/mobile/lib/providers/background_sync.provider.dart @@ -9,6 +9,7 @@ final backgroundSyncProvider = Provider((ref) { final manager = BackgroundSyncManager( onRemoteSyncStart: () { syncStatusNotifier.startRemoteSync(); + // ignore: dispose-provided-instances final backupProvider = ref.read(driftBackupProvider.notifier); if (backupProvider.mounted) { backupProvider.updateError(BackupError.none); @@ -16,6 +17,7 @@ final backgroundSyncProvider = Provider((ref) { }, onRemoteSyncComplete: (isSuccess) { syncStatusNotifier.completeRemoteSync(); + // ignore: dispose-provided-instances final backupProvider = ref.read(driftBackupProvider.notifier); if (backupProvider.mounted) { backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed); diff --git a/mobile/lib/providers/backup/backup_album.provider.dart b/mobile/lib/providers/backup/backup_album.provider.dart index 25a4204928..17d275ff51 100644 --- a/mobile/lib/providers/backup/backup_album.provider.dart +++ b/mobile/lib/providers/backup/backup_album.provider.dart @@ -4,10 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/services/local_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; final backupAlbumProvider = StateNotifierProvider>( - (ref) => BackupAlbumNotifier(ref.watch(localAlbumServiceProvider)), + (ref) => BackupAlbumNotifier(LocalAlbumService(ref.watch(driftProvider).localAlbumRepository)), ); class BackupAlbumNotifier extends StateNotifier> { @@ -22,40 +22,25 @@ class BackupAlbumNotifier extends StateNotifier> { } Future selectAlbum(LocalAlbum album) async { - album = album.copyWith(backupSelection: BackupSelection.selected); - await _localAlbumService.update(album); + final selectedAlbum = album.copyWith(backupSelection: BackupSelection.selected); + await _localAlbumService.update(selectedAlbum); - state = state - .map( - (currentAlbum) => currentAlbum.id == album.id - ? currentAlbum.copyWith(backupSelection: BackupSelection.selected) - : currentAlbum, - ) - .toList(); + state = state.map((currentAlbum) => currentAlbum.id == selectedAlbum.id ? selectedAlbum : currentAlbum).toList(); } Future deselectAlbum(LocalAlbum album) async { - album = album.copyWith(backupSelection: BackupSelection.none); - await _localAlbumService.update(album); + final deselectedAlbum = album.copyWith(backupSelection: BackupSelection.none); + await _localAlbumService.update(deselectedAlbum); state = state - .map( - (currentAlbum) => - currentAlbum.id == album.id ? currentAlbum.copyWith(backupSelection: BackupSelection.none) : currentAlbum, - ) + .map((currentAlbum) => currentAlbum.id == deselectedAlbum.id ? deselectedAlbum : currentAlbum) .toList(); } Future excludeAlbum(LocalAlbum album) async { - album = album.copyWith(backupSelection: BackupSelection.excluded); - await _localAlbumService.update(album); + final excludedAlbum = album.copyWith(backupSelection: BackupSelection.excluded); + await _localAlbumService.update(excludedAlbum); - state = state - .map( - (currentAlbum) => currentAlbum.id == album.id - ? currentAlbum.copyWith(backupSelection: BackupSelection.excluded) - : currentAlbum, - ) - .toList(); + state = state.map((currentAlbum) => currentAlbum.id == excludedAlbum.id ? excludedAlbum : currentAlbum).toList(); } } diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index b43fec023c..99b84cf95f 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,17 +1,20 @@ import 'dart:async'; -import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/upload_speed_calculator.dart'; import 'package:logging/logging.dart'; +part 'drift_backup.provider.freezed.dart'; + class EnqueueStatus { final int enqueueCount; final int totalCount; @@ -26,160 +29,37 @@ class EnqueueStatus { String toString() => 'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)'; } -class DriftUploadStatus { - final String taskId; - final String filename; - final double progress; - final int fileSize; - final String networkSpeedAsString; - final bool? isFailed; - final String? error; - - const DriftUploadStatus({ - required this.taskId, - required this.filename, - required this.progress, - required this.fileSize, - required this.networkSpeedAsString, - this.isFailed, - this.error, - }); - - DriftUploadStatus copyWith({ - String? taskId, - String? filename, - double? progress, - int? fileSize, - String? networkSpeedAsString, +@freezed +abstract class DriftUploadStatus with _$DriftUploadStatus { + const factory DriftUploadStatus({ + required String taskId, + required String filename, + required double progress, + required int fileSize, + required String networkSpeedAsString, bool? isFailed, String? error, - }) { - return DriftUploadStatus( - taskId: taskId ?? this.taskId, - filename: filename ?? this.filename, - progress: progress ?? this.progress, - fileSize: fileSize ?? this.fileSize, - networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString, - isFailed: isFailed ?? this.isFailed, - error: error ?? this.error, - ); - } - - @override - String toString() { - return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString, isFailed: $isFailed, error: $error)'; - } - - @override - bool operator ==(covariant DriftUploadStatus other) { - if (identical(this, other)) { - return true; - } - - return other.taskId == taskId && - other.filename == filename && - other.progress == progress && - other.fileSize == fileSize && - other.networkSpeedAsString == networkSpeedAsString && - other.isFailed == isFailed && - other.error == error; - } - - @override - int get hashCode { - return taskId.hashCode ^ - filename.hashCode ^ - progress.hashCode ^ - fileSize.hashCode ^ - networkSpeedAsString.hashCode ^ - isFailed.hashCode ^ - error.hashCode; - } + }) = _DriftUploadStatus; } enum BackupError { none, syncFailed } -class DriftBackupState { - final int totalCount; - final int backupCount; - final int remainderCount; - final int processingCount; +@freezed +abstract class DriftBackupState with _$DriftBackupState { + const DriftBackupState._(); - final bool isSyncing; - final BackupError error; - - final Map uploadItems; - - final Map iCloudDownloadProgress; - - const DriftBackupState({ - required this.totalCount, - required this.backupCount, - required this.remainderCount, - required this.processingCount, - required this.isSyncing, - this.error = BackupError.none, - required this.uploadItems, - this.iCloudDownloadProgress = const {}, - }); - - DriftBackupState copyWith({ - int? totalCount, - int? backupCount, - int? remainderCount, - int? processingCount, - bool? isSyncing, - BackupError? error, - Map? uploadItems, - Map? iCloudDownloadProgress, - }) { - return DriftBackupState( - totalCount: totalCount ?? this.totalCount, - backupCount: backupCount ?? this.backupCount, - remainderCount: remainderCount ?? this.remainderCount, - processingCount: processingCount ?? this.processingCount, - isSyncing: isSyncing ?? this.isSyncing, - error: error ?? this.error, - uploadItems: uploadItems ?? this.uploadItems, - iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, - ); - } + const factory DriftBackupState({ + required int totalCount, + required int backupCount, + required int remainderCount, + required int processingCount, + required bool isSyncing, + @Default(BackupError.none) BackupError error, + required Map uploadItems, + @Default({}) Map iCloudDownloadProgress, + }) = _DriftBackupState; int get errorCount => uploadItems.values.where((item) => item.isFailed == true).length; - - @override - String toString() { - return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, iCloudDownloadProgress: $iCloudDownloadProgress)'; - } - - @override - bool operator ==(covariant DriftBackupState other) { - if (identical(this, other)) { - return true; - } - final mapEquals = const DeepCollectionEquality().equals; - - return other.totalCount == totalCount && - other.backupCount == backupCount && - other.remainderCount == remainderCount && - other.processingCount == processingCount && - other.isSyncing == isSyncing && - other.error == error && - mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && - mapEquals(other.uploadItems, uploadItems); - } - - @override - int get hashCode { - return totalCount.hashCode ^ - backupCount.hashCode ^ - remainderCount.hashCode ^ - processingCount.hashCode ^ - isSyncing.hashCode ^ - error.hashCode ^ - uploadItems.hashCode ^ - iCloudDownloadProgress.hashCode; - } } final driftBackupProvider = StateNotifierProvider((ref) { @@ -255,19 +135,25 @@ class DriftBackupNotifier extends StateNotifier { state = state.copyWith(isSyncing: isSyncing); } - Future startForegroundBackup(String userId) { + Future startForegroundBackup(String userId) async { // Cancel any existing backup before starting a new one if (_cancelToken != null) { - stopForegroundBackup(); + stopForegroundBackup(reason: "restarting the backup"); } state = state.copyWith(error: BackupError.none); - _cancelToken = Completer(); + // A pause during the recount below nulls _cancelToken, so the run keeps its own reference. + final cancelToken = Completer(); + _cancelToken = cancelToken; + + // Re-baseline the counters against the same DB read that feeds this run's candidate list, + // otherwise a resume counts duplicate successes against the old baseline (#26215). + await getBackupStatus(userId); return _foregroundUploadService.uploadCandidates( userId, - _cancelToken!, + cancelToken, callbacks: UploadCallbacks( onProgress: _handleForegroundBackupProgress, onSuccess: _handleForegroundBackupSuccess, @@ -277,7 +163,10 @@ class DriftBackupNotifier extends StateNotifier { ); } - void stopForegroundBackup() { + void stopForegroundBackup({required String reason}) { + if (_cancelToken != null) { + _logger.info("Foreground backup cancelled: $reason"); + } _cancelToken?.complete(); _cancelToken = null; _uploadSpeedManager.clear(); @@ -333,6 +222,10 @@ class DriftBackupNotifier extends StateNotifier { } void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) { + if (!mounted) { + _logger.warning("Skip _handleForegroundBackupSuccess: notifier disposed"); + return; + } state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1); _uploadSpeedManager.removeTask(localAssetId); @@ -409,5 +302,8 @@ final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family< ref, assetId, ) { - return ref.read(localAssetRepository).getSourceAlbums(assetId, backupSelection: BackupSelection.selected); + return ref + .read(driftProvider) + .localAssetRepository + .getSourceAlbums(assetId, backupSelection: BackupSelection.selected); }); diff --git a/mobile/lib/providers/gallery_permission.provider.dart b/mobile/lib/providers/gallery_permission.provider.dart index 6d4703c834..6be504bd2e 100644 --- a/mobile/lib/providers/gallery_permission.provider.dart +++ b/mobile/lib/providers/gallery_permission.provider.dart @@ -1,109 +1,28 @@ import 'dart:async'; -import 'dart:io'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:permission_handler/permission_handler.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/services/device_permission.service.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; -class GalleryPermissionNotifier extends StateNotifier { - GalleryPermissionNotifier() - : super(PermissionStatus.denied) // Denied is the initial state - { - // Sets the initial state +final galleryPermissionServiceProvider = Provider((ref) { + return DevicePermissionService(ref.watch(permissionRepositoryProvider)); +}); + +class GalleryPermissionNotifier extends StateNotifier { + GalleryPermissionNotifier(this._service) : super(.denied) { unawaited(getGalleryPermissionStatus()); } - bool get hasPermission => state.isGranted || state.isLimited; + final DevicePermissionService _service; - /// Requests the gallery permission - Future requestGalleryPermission() async { - PermissionStatus result; - // Android 32 and below uses Permission.storage - if (Platform.isAndroid) { - final androidInfo = await DeviceInfoPlugin().androidInfo; - if (androidInfo.version.sdkInt <= 32) { - // Android 32 and below need storage - final permission = await Permission.storage.request(); - result = permission; - } else { - // Android 33 need photo & video - final photos = await Permission.photos.request(); - if (!photos.isGranted) { - // Don't ask twice for the same permission - state = photos; - return photos; - } - final videos = await Permission.videos.request(); + bool get hasPermission => state.hasAccess; - // Return the joint result of those two permissions - final PermissionStatus status; - if ((photos.isGranted && videos.isGranted) || (photos.isLimited && videos.isLimited)) { - status = PermissionStatus.granted; - } else if (photos.isDenied || videos.isDenied) { - status = PermissionStatus.denied; - } else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) { - status = PermissionStatus.permanentlyDenied; - } else { - status = PermissionStatus.denied; - } + Future requestGalleryPermission() async => state = await _service.requestGallery(); - result = status; - } - if (result == PermissionStatus.granted && androidInfo.version.sdkInt >= 29) { - result = await Permission.accessMediaLocation.request(); - } - } else { - // iOS can use photos - final photos = await Permission.photos.request(); - result = photos; - } - state = result; - return result; - } - - /// Checks the current state of the gallery permissions without - /// requesting them again - Future getGalleryPermissionStatus() async { - PermissionStatus result; - // Android 32 and below uses Permission.storage - if (Platform.isAndroid) { - final androidInfo = await DeviceInfoPlugin().androidInfo; - if (androidInfo.version.sdkInt <= 32) { - // Android 32 and below need storage - final permission = await Permission.storage.status; - result = permission; - } else { - // Android 33 needs photo & video - final photos = await Permission.photos.status; - final videos = await Permission.videos.status; - - // Return the joint result of those two permissions - final PermissionStatus status; - if ((photos.isGranted && videos.isGranted) || (photos.isLimited && videos.isLimited)) { - status = PermissionStatus.granted; - } else if (photos.isDenied || videos.isDenied) { - status = PermissionStatus.denied; - } else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) { - status = PermissionStatus.permanentlyDenied; - } else { - status = PermissionStatus.denied; - } - - result = status; - } - if (state == PermissionStatus.granted && androidInfo.version.sdkInt >= 29) { - result = await Permission.accessMediaLocation.status; - } - } else { - // iOS can use photos - final photos = await Permission.photos.status; - result = photos; - } - state = result; - return result; - } + Future getGalleryPermissionStatus() async => state = await _service.galleryStatus(); } -final galleryPermissionNotifier = StateNotifierProvider( - (ref) => GalleryPermissionNotifier(), +final galleryPermissionNotifier = StateNotifierProvider( + (ref) => GalleryPermissionNotifier(ref.watch(galleryPermissionServiceProvider)), ); diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 919836737f..3fb9f8acbc 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'dart:async'; import 'package:auto_route/auto_route.dart'; diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index 379e7b3101..579cc17838 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -5,41 +5,27 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/local_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; -final localAlbumRepository = Provider( - (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), -); - -final localAlbumServiceProvider = Provider( - (ref) => LocalAlbumService(ref.watch(localAlbumRepository)), -); - final localAlbumProvider = FutureProvider>( - (ref) => LocalAlbumService(ref.watch(localAlbumRepository)) + (ref) => LocalAlbumService(ref.watch(driftProvider).localAlbumRepository) .getAll(sortBy: {SortLocalAlbumsBy.newestAsset}) .then((albums) => albums.where((album) => album.assetCount > 0).toList()), ); final localAlbumThumbnailProvider = FutureProvider.family( - (ref, albumId) => LocalAlbumService(ref.watch(localAlbumRepository)).getThumbnail(albumId), -); - -final remoteAlbumRepository = Provider( - (ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)), + (ref, albumId) => LocalAlbumService(ref.watch(driftProvider).localAlbumRepository).getThumbnail(albumId), ); final remoteAlbumServiceProvider = Provider( (ref) => RemoteAlbumService( - ref.watch(remoteAlbumRepository), + ref.watch(driftProvider).remoteAlbumRepository, ref.watch(driftAlbumApiRepositoryProvider), ref.watch(foregroundUploadServiceProvider), ), - dependencies: [remoteAlbumRepository], ); final remoteAlbumProvider = NotifierProvider( diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 8ca0ca0ec9..ea89d1b9fb 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -1,38 +1,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; -final localAssetRepository = Provider( - (ref) => DriftLocalAssetRepository(ref.watch(driftProvider)), -); - -final remoteAssetRepositoryProvider = Provider( - (ref) => RemoteAssetRepository(ref.watch(driftProvider)), -); - -final remoteExifRepositoryProvider = Provider((ref) => RemoteExifRepository(ref.watch(driftProvider))); - -final trashedLocalAssetRepository = Provider( - (ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)), -); - -final assetServiceProvider = Provider( - (ref) => AssetService( - remoteRepository: ref.watch(remoteAssetRepositoryProvider), - exifRepository: ref.watch(remoteExifRepositoryProvider), - localRepository: ref.watch(localAssetRepository), +final assetServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return AssetService( + remoteRepository: db.remoteAssetRepository, + exifRepository: db.remoteExifRepository, + localRepository: db.localAssetRepository, apiRepository: ref.watch(assetApiRepositoryProvider), mediaRepository: ref.watch(assetMediaRepositoryProvider), - trashedLocalRepository: ref.watch(trashedLocalAssetRepository), - ), -); + trashedLocalRepository: db.trashedLocalAssetRepository, + ); +}); final placesProvider = FutureProvider>((ref) { final assetService = ref.watch(assetServiceProvider); diff --git a/mobile/lib/providers/infrastructure/map.provider.dart b/mobile/lib/providers/infrastructure/map.provider.dart index d9d261521e..7f2fa5b40c 100644 --- a/mobile/lib/providers/infrastructure/map.provider.dart +++ b/mobile/lib/providers/infrastructure/map.provider.dart @@ -1,13 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/map.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -final mapRepositoryProvider = Provider((ref) => DriftMapRepository(ref.watch(driftProvider))); - final mapServiceProvider = Provider( (ref) { final user = ref.watch(currentUserProvider); @@ -19,12 +16,11 @@ final mapServiceProvider = Provider( ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] : [user.id]; - final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions()); + final mapFactory = MapFactory(mapRepository: ref.watch(driftProvider).mapRepository); + final mapService = mapFactory.remote(users, ref.watch(mapStateProvider).toOptions()); return mapService; }, // Empty dependencies to inform the framework that this provider // might be used in a ProviderScope dependencies: const [], ); - -final mapFactoryProvider = Provider((ref) => MapFactory(mapRepository: ref.watch(mapRepositoryProvider))); diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart index 3dd32454b8..a2817f9c07 100644 --- a/mobile/lib/providers/infrastructure/memory.provider.dart +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -3,18 +3,9 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/services/memory.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -final driftMemoryRepositoryProvider = Provider( - (ref) => DriftMemoryRepository(ref.watch(driftProvider)), -); - -final driftMemoryServiceProvider = Provider( - (ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)), -); - final driftMemoryFutureProvider = FutureProvider.autoDispose>((ref) { final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true))); if (userId == null || !enabled) { @@ -26,6 +17,6 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose>( final timer = Timer(nextMidnight.difference(now) + const Duration(seconds: 5), ref.invalidateSelf); ref.onDispose(timer.cancel); - final service = ref.watch(driftMemoryServiceProvider); + final service = DriftMemoryService(ref.watch(driftProvider).memoryRepository); return service.getMemoryLane(userId); }); diff --git a/mobile/lib/providers/infrastructure/ocr.provider.dart b/mobile/lib/providers/infrastructure/ocr.provider.dart index 111f3952ce..20cfdbf5b2 100644 --- a/mobile/lib/providers/infrastructure/ocr.provider.dart +++ b/mobile/lib/providers/infrastructure/ocr.provider.dart @@ -1,14 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/ocr.model.dart'; import 'package:immich_mobile/domain/services/ocr.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -final ocrRepositoryProvider = Provider((ref) => OcrRepository(ref.watch(driftProvider))); - -final ocrServiceProvider = Provider((ref) => OcrService(ref.watch(ocrRepositoryProvider))); - final ocrAssetProvider = FutureProvider.autoDispose.family?, String>((ref, assetId) async { - final service = ref.watch(ocrServiceProvider); + final service = OcrService(ref.watch(driftProvider).ocrRepository); return service.get(assetId); }); diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart index 1939940975..3d8c674efa 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -1,26 +1,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/domain/services/people.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/people.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/repositories/person_api.repository.dart'; -final driftPeopleRepositoryProvider = Provider( - (ref) => DriftPeopleRepository(ref.watch(driftProvider)), -); - final driftPeopleServiceProvider = Provider( - (ref) => DriftPeopleService(ref.watch(driftPeopleRepositoryProvider), ref.watch(personApiRepositoryProvider)), + (ref) => DriftPeopleService(ref.watch(driftProvider).peopleRepository, ref.watch(personApiRepositoryProvider)), ); -final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { +final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { final service = ref.watch(driftPeopleServiceProvider); return service.getAssetPeople(assetId); }); -final driftGetAllPeopleProvider = FutureProvider>((ref) async { +final getAllPeopleProvider = StreamProvider>((ref) async* { final service = ref.watch(driftPeopleServiceProvider); final prefs = await ref.watch(userMetadataPreferencesProvider.future); - return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3); + yield* service.watch(minFaces: prefs?.minimumFaces ?? 3); }); diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index b392f5f401..b76a8213c6 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -1,6 +1,8 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'dart:async'; -import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -15,30 +17,17 @@ import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:logging/logging.dart'; -class RemoteAlbumState { - final List albums; +part 'remote_album.provider.freezed.dart'; - const RemoteAlbumState({required this.albums}); +@Freezed(toStringOverride: false) +abstract class RemoteAlbumState with _$RemoteAlbumState { + const RemoteAlbumState._(); - RemoteAlbumState copyWith({List? albums}) { - return RemoteAlbumState(albums: albums ?? this.albums); - } + const factory RemoteAlbumState({required List albums}) = _RemoteAlbumState; + // Explicitly don't log albums @override String toString() => 'RemoteAlbumState(albums: ${albums.length})'; - - @override - bool operator ==(covariant RemoteAlbumState other) { - if (identical(this, other)) { - return true; - } - final listEquals = const DeepCollectionEquality().equals; - - return listEquals(other.albums, albums); - } - - @override - int get hashCode => albums.hashCode; } class RemoteAlbumNotifier extends Notifier { diff --git a/mobile/lib/providers/infrastructure/setting.provider.dart b/mobile/lib/providers/infrastructure/setting.provider.dart index 7d8be72cd0..9e33017cab 100644 --- a/mobile/lib/providers/infrastructure/setting.provider.dart +++ b/mobile/lib/providers/infrastructure/setting.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; diff --git a/mobile/lib/providers/infrastructure/stack.provider.dart b/mobile/lib/providers/infrastructure/stack.provider.dart deleted file mode 100644 index 0528fd0c91..0000000000 --- a/mobile/lib/providers/infrastructure/stack.provider.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; - -final driftStackProvider = Provider((ref) => DriftStackRepository(ref.watch(driftProvider))); diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index 700b51f12d..618c6039ff 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -3,55 +3,50 @@ import 'package:immich_mobile/domain/services/hash.service.dart'; import 'package:immich_mobile/domain/services/local_sync.service.dart'; import 'package:immich_mobile/domain/services/sync_stream.service.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/permission.repository.dart'; -final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider))); - -final syncStreamServiceProvider = Provider( - (ref) => SyncStreamService( +final syncStreamServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return SyncStreamService( syncApiRepository: ref.watch(syncApiRepositoryProvider), - syncStreamRepository: ref.watch(syncStreamRepositoryProvider), - localAssetRepository: ref.watch(localAssetRepository), - trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), + syncStreamRepository: db.syncStreamRepository, + localAssetRepository: db.localAssetRepository, + trashedLocalAssetRepository: db.trashedLocalAssetRepository, assetMediaRepository: ref.watch(assetMediaRepositoryProvider), permissionRepository: ref.watch(permissionRepositoryProvider), - syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider), + syncMigrationRepository: db.syncMigrationRepository, api: ref.watch(apiServiceProvider), cancellation: ref.watch(cancellationProvider), - ), -); + ); +}); final syncApiRepositoryProvider = Provider((ref) => SyncApiRepository(ref.watch(apiServiceProvider))); -final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref.watch(driftProvider))); - -final localSyncServiceProvider = Provider( - (ref) => LocalSyncService( - localAlbumRepository: ref.watch(localAlbumRepository), - localAssetRepository: ref.watch(localAssetRepository), - trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), +final localSyncServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return LocalSyncService( + localAlbumRepository: db.localAlbumRepository, + localAssetRepository: db.localAssetRepository, + trashedLocalAssetRepository: db.trashedLocalAssetRepository, assetMediaRepository: ref.watch(assetMediaRepositoryProvider), permissionRepository: ref.watch(permissionRepositoryProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider), cancellation: ref.watch(cancellationProvider), - ), -); + ); +}); -final hashServiceProvider = Provider( - (ref) => HashService( - localAlbumRepository: ref.watch(localAlbumRepository), - localAssetRepository: ref.watch(localAssetRepository), +final hashServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return HashService( + localAlbumRepository: db.localAlbumRepository, + localAssetRepository: db.localAssetRepository, nativeSyncApi: ref.watch(nativeSyncApiProvider), - trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), + trashedLocalAssetRepository: db.trashedLocalAssetRepository, cancellation: ref.watch(cancellationProvider), - ), -); + ); +}); diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart index 2d527768c5..152582aa93 100644 --- a/mobile/lib/providers/infrastructure/tag.provider.dart +++ b/mobile/lib/providers/infrastructure/tag.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/services/tag.service.dart'; diff --git a/mobile/lib/providers/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart index e5ac1389e9..667b2624d7 100644 --- a/mobile/lib/providers/infrastructure/timeline.provider.dart +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -1,16 +1,11 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -final timelineRepositoryProvider = Provider( - (ref) => DriftTimelineRepository(ref.watch(driftProvider)), -); - final timelineArgsProvider = Provider.autoDispose( (ref) => throw UnimplementedError('Will be overridden through a ProviderScope.'), ); @@ -29,7 +24,7 @@ final timelineServiceProvider = Provider( final timelineFactoryProvider = Provider( (ref) => TimelineFactory( - timelineRepository: ref.watch(timelineRepositoryProvider), + timelineRepository: ref.watch(driftProvider).timelineRepository, settingsRepository: ref.watch(settingsProvider), ), ); @@ -43,7 +38,8 @@ final timelineUsersProvider = StreamProvider>((ref) { // Drift re-emits a fresh but content-identical list on unrelated table updates, // which would dispose and rebuild the timeline service mid-load return ref - .watch(timelineRepositoryProvider) + .watch(driftProvider) + .timelineRepository .watchTimelineUserIds(currentUserId) .distinct(const ListEquality().equals); }); diff --git a/mobile/lib/providers/infrastructure/trash_sync.provider.dart b/mobile/lib/providers/infrastructure/trash_sync.provider.dart index a783080f33..13e1424d75 100644 --- a/mobile/lib/providers/infrastructure/trash_sync.provider.dart +++ b/mobile/lib/providers/infrastructure/trash_sync.provider.dart @@ -1,11 +1,11 @@ import 'package:async/async.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; typedef TrashedAssetsCount = ({int total, int hashed}); final trashedAssetsCountProvider = StreamProvider((ref) { - final repo = ref.watch(trashedLocalAssetRepository); + final repo = ref.watch(driftProvider).trashedLocalAssetRepository; final total$ = repo.watchCount(); final hashed$ = repo.watchHashedCount(); return StreamZip([total$, hashed$]).map((values) => (total: values[0], hashed: values[1])); diff --git a/mobile/lib/providers/infrastructure/user.provider.dart b/mobile/lib/providers/infrastructure/user.provider.dart index 09f74db37d..a36082f31b 100644 --- a/mobile/lib/providers/infrastructure/user.provider.dart +++ b/mobile/lib/providers/infrastructure/user.provider.dart @@ -1,16 +1,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; -final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(driftProvider))); - final userApiRepositoryProvider = Provider((ref) => UserApiRepository(ref.watch(apiServiceProvider).usersApi)); final userServiceProvider = Provider( @@ -20,12 +16,7 @@ final userServiceProvider = Provider( ), ); -final partnerRepositoryProvider = Provider((ref) => PartnerRepository(ref.watch(driftProvider))); - -final partnerServiceProvider = Provider( - (ref) => PartnerService( - ref.watch(userRepositoryProvider), - ref.watch(partnerRepositoryProvider), - ref.watch(partnerApiRepositoryProvider), - ), -); +final partnerServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return PartnerService(db.userRepository, db.partnerRepository, ref.watch(partnerApiRepositoryProvider)); +}); diff --git a/mobile/lib/providers/infrastructure/user_metadata.provider.dart b/mobile/lib/providers/infrastructure/user_metadata.provider.dart index 357b43f4f6..dd29b41a44 100644 --- a/mobile/lib/providers/infrastructure/user_metadata.provider.dart +++ b/mobile/lib/providers/infrastructure/user_metadata.provider.dart @@ -1,16 +1,11 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user_metadata.model.dart'; -import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -final userMetadataRepository = Provider( - (ref) => DriftUserMetadataRepository(ref.watch(driftProvider)), -); - final userMetadataProvider = FutureProvider>((ref) async { - final repository = ref.watch(userMetadataRepository); + final repository = ref.watch(driftProvider).userMetadataRepository; final user = ref.watch(currentUserProvider); if (user == null) { return []; diff --git a/mobile/lib/providers/local_auth.provider.dart b/mobile/lib/providers/local_auth.provider.dart index 58bd4fb0f8..d99f28823f 100644 --- a/mobile/lib/providers/local_auth.provider.dart +++ b/mobile/lib/providers/local_auth.provider.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/auth/biometric_status.model.dart'; import 'package:immich_mobile/services/local_auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; @@ -43,6 +43,7 @@ class LocalAuthNotifier extends StateNotifier { } Future authenticate(BuildContext context, String? message) async { + final t = StaticTranslations.instance; String errorMessage = ""; try { @@ -51,20 +52,20 @@ class LocalAuthNotifier extends StateNotifier { switch (error.code) { case "NotEnrolled": _log.warning("User is not enrolled in biometrics"); - errorMessage = "biometric_no_options".tr(); + errorMessage = t.biometric_no_options; case "NotAvailable": _log.warning("Biometric authentication is not available"); - errorMessage = "biometric_not_available".tr(); + errorMessage = t.biometric_not_available; case "LockedOut": _log.warning("User is locked out of biometric authentication"); - errorMessage = "biometric_locked_out".tr(); + errorMessage = t.biometric_locked_out; default: _log.warning("Failed to authenticate with unknown reason"); - errorMessage = 'failed_to_authenticate'.tr(); + errorMessage = t.failed_to_authenticate; } } catch (error) { _log.warning("Error during authentication: $error"); - errorMessage = 'failed_to_authenticate'.tr(); + errorMessage = t.failed_to_authenticate; } finally { if (errorMessage.isNotEmpty) { context.showSnackBar( diff --git a/mobile/lib/providers/permission.provider.dart b/mobile/lib/providers/permission.provider.dart index dc82285122..bd4dc7b61e 100644 --- a/mobile/lib/providers/permission.provider.dart +++ b/mobile/lib/providers/permission.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'dart:async'; import 'dart:io'; diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart index 6cc3d08c5d..776389b330 100644 --- a/mobile/lib/providers/routes.provider.dart +++ b/mobile/lib/providers/routes.provider.dart @@ -5,9 +5,6 @@ import 'package:immich_mobile/routing/router.dart'; @visibleForTesting bool isRouteInStack(Ref ref, String routeName) { final router = ref.watch(appRouterProvider); - void onChange() => ref.invalidateSelf(); - router.addListener(onChange); - ref.onDispose(() => router.removeListener(onChange)); return router.stackData.any((route) => route.name == routeName); } diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart deleted file mode 100644 index 1bd58509f5..0000000000 --- a/mobile/lib/providers/search/people.provider.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/services/person.service.dart'; - -final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async { - final PersonService personService = ref.read(personServiceProvider); - - final people = await personService.getAllPeople(); - - return people; -}); - -final updatePersonNameProvider = FutureProvider.autoDispose( - (ref) => (String personId, String updatedName) async { - final PersonService personService = ref.read(personServiceProvider); - final person = await personService.updateName(personId, updatedName); - - if (person != null && person.name == updatedName) { - ref.invalidate(getAllPeopleProvider); - return true; - } - return false; - }, -); diff --git a/mobile/lib/providers/search/search_filter.provider.dart b/mobile/lib/providers/search/search_filter.provider.dart index b171de50c7..5da0a11fe7 100644 --- a/mobile/lib/providers/search/search_filter.provider.dart +++ b/mobile/lib/providers/search/search_filter.provider.dart @@ -1,34 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/search.service.dart'; import 'package:openapi/api.dart'; -class SearchSuggestionArgs { - SearchSuggestionType type; - final String? locationCountry; - final String? locationState; - final String? make; - final String? model; +part 'search_filter.provider.freezed.dart'; - SearchSuggestionArgs({required this.type, this.locationCountry, this.locationState, this.make, this.model}); - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is SearchSuggestionArgs && - other.type == type && - other.locationCountry == locationCountry && - other.locationState == locationState && - other.make == make && - other.model == model; - } - - @override - int get hashCode { - return type.hashCode ^ locationCountry.hashCode ^ locationState.hashCode ^ make.hashCode ^ model.hashCode; - } +@freezed +abstract class SearchSuggestionArgs with _$SearchSuggestionArgs { + const factory SearchSuggestionArgs({ + required SearchSuggestionType type, + String? locationCountry, + String? locationState, + String? make, + String? model, + }) = _SearchSuggestionArgs; } final getSearchSuggestionsProvider = FutureProvider.autoDispose.family, SearchSuggestionArgs>(( diff --git a/mobile/lib/providers/search/search_input_focus.provider.dart b/mobile/lib/providers/search/search_input_focus.provider.dart index 4f6ed41ee0..4ebbfc6553 100644 --- a/mobile/lib/providers/search/search_input_focus.provider.dart +++ b/mobile/lib/providers/search/search_input_focus.provider.dart @@ -2,5 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; final searchInputFocusProvider = Provider((ref) { - return FocusNode(); + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; }); diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index c25e496a04..79593cce95 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -59,7 +59,7 @@ class ServerInfoNotifier extends StateNotifier { } Future _checkServerVersionMismatch(ServerVersion serverVersion, {ServerVersion? latestVersion}) async { - state = state.copyWith(serverVersion: serverVersion, latestVersion: latestVersion); + state = state.copyWith(serverVersion: serverVersion, latestVersion: latestVersion ?? state.latestVersion); final packageInfo = await PackageInfo.fromPlatform(); final SemVer clientVersion = SemVer.fromString(packageInfo.version); diff --git a/mobile/lib/providers/sync_status.provider.dart b/mobile/lib/providers/sync_status.provider.dart index b7d4f8bdc3..eb183fbb13 100644 --- a/mobile/lib/providers/sync_status.provider.dart +++ b/mobile/lib/providers/sync_status.provider.dart @@ -1,5 +1,9 @@ -import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; + +part 'sync_status.provider.freezed.dart'; enum SyncStatus { idle, @@ -8,67 +12,32 @@ enum SyncStatus { error; String localized() { + final t = StaticTranslations.instance; return switch (this) { - SyncStatus.idle => "idle".tr(), - SyncStatus.syncing => "running".tr(), - SyncStatus.success => "success".tr(), - SyncStatus.error => "error".tr(), + SyncStatus.idle => t.idle, + SyncStatus.syncing => t.running, + SyncStatus.success => t.success, + SyncStatus.error => t.error, }; } } -class SyncStatusState { - final SyncStatus remoteSyncStatus; - final SyncStatus localSyncStatus; - final SyncStatus hashJobStatus; - final SyncStatus cloudIdSyncStatus; +@freezed +abstract class SyncStatusState with _$SyncStatusState { + const SyncStatusState._(); - final String? errorMessage; - - const SyncStatusState({ - this.remoteSyncStatus = SyncStatus.idle, - this.localSyncStatus = SyncStatus.idle, - this.hashJobStatus = SyncStatus.idle, - this.cloudIdSyncStatus = SyncStatus.idle, - this.errorMessage, - }); - - SyncStatusState copyWith({ - SyncStatus? remoteSyncStatus, - SyncStatus? localSyncStatus, - SyncStatus? hashJobStatus, - SyncStatus? cloudIdSyncStatus, + const factory SyncStatusState({ + @Default(SyncStatus.idle) SyncStatus remoteSyncStatus, + @Default(SyncStatus.idle) SyncStatus localSyncStatus, + @Default(SyncStatus.idle) SyncStatus hashJobStatus, + @Default(SyncStatus.idle) SyncStatus cloudIdSyncStatus, String? errorMessage, - }) { - return SyncStatusState( - remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus, - localSyncStatus: localSyncStatus ?? this.localSyncStatus, - hashJobStatus: hashJobStatus ?? this.hashJobStatus, - cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus, - errorMessage: errorMessage ?? this.errorMessage, - ); - } + }) = _SyncStatusState; bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing; bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing; bool get isHashing => hashJobStatus == SyncStatus.syncing; bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - return other is SyncStatusState && - other.remoteSyncStatus == remoteSyncStatus && - other.localSyncStatus == localSyncStatus && - other.hashJobStatus == hashJobStatus && - other.cloudIdSyncStatus == cloudIdSyncStatus && - other.errorMessage == errorMessage; - } - - @override - int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage); } class SyncStatusNotifier extends Notifier { @@ -88,7 +57,11 @@ class SyncStatusNotifier extends Notifier { /// void setRemoteSyncStatus(SyncStatus status, [String? errorMessage]) { - state = state.copyWith(remoteSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null); + // TODO(agg23): These error messages probably should be cleared, not preserved on null + state = state.copyWith( + remoteSyncStatus: status, + errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage, + ); } void startRemoteSync() => setRemoteSyncStatus(SyncStatus.syncing); @@ -100,7 +73,11 @@ class SyncStatusNotifier extends Notifier { /// void setLocalSyncStatus(SyncStatus status, [String? errorMessage]) { - state = state.copyWith(localSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null); + // TODO(agg23): These error messages probably should be cleared, not preserved on null + state = state.copyWith( + localSyncStatus: status, + errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage, + ); } void startLocalSync() => setLocalSyncStatus(SyncStatus.syncing); @@ -112,7 +89,11 @@ class SyncStatusNotifier extends Notifier { /// void setHashJobStatus(SyncStatus status, [String? errorMessage]) { - state = state.copyWith(hashJobStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null); + // TODO(agg23): These error messages probably should be cleared, not preserved on null + state = state.copyWith( + hashJobStatus: status, + errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage, + ); } void startHashJob() => setHashJobStatus(SyncStatus.syncing); @@ -124,7 +105,11 @@ class SyncStatusNotifier extends Notifier { /// void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) { - state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null); + // TODO(agg23): These error messages probably should be cleared, not preserved on null + state = state.copyWith( + cloudIdSyncStatus: status, + errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage, + ); } void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing); diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index 64edd4cd3f..f74201e983 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -1,20 +1,27 @@ -import 'package:collection/collection.dart'; +// ignore_for_file: use-ref-and-state-synchronously + +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +part 'multiselect.provider.freezed.dart'; + final multiSelectProvider = NotifierProvider( MultiSelectNotifier.new, dependencies: [timelineServiceProvider], ); -class MultiSelectState { - final Set selectedAssets; - final Set lockedSelectionAssets; - final bool forceEnable; +@freezed +abstract class MultiSelectState with _$MultiSelectState { + const MultiSelectState._(); - const MultiSelectState({required this.selectedAssets, required this.lockedSelectionAssets, this.forceEnable = false}); + const factory MultiSelectState({ + required Set selectedAssets, + required Set lockedSelectionAssets, + @Default(false) bool forceEnable, + }) = _MultiSelectState; bool get isEnabled => selectedAssets.isNotEmpty; @@ -27,37 +34,6 @@ class MultiSelectState { bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local); bool get onlyRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote); - - MultiSelectState copyWith({ - Set? selectedAssets, - Set? lockedSelectionAssets, - bool? forceEnable, - }) { - return MultiSelectState( - selectedAssets: selectedAssets ?? this.selectedAssets, - lockedSelectionAssets: lockedSelectionAssets ?? this.lockedSelectionAssets, - forceEnable: forceEnable ?? this.forceEnable, - ); - } - - @override - String toString() => - 'MultiSelectState(selectedAssets: $selectedAssets, lockedSelectionAssets: $lockedSelectionAssets, forceEnable: $forceEnable)'; - - @override - bool operator ==(covariant MultiSelectState other) { - if (identical(this, other)) { - return true; - } - final setEquals = const DeepCollectionEquality().equals; - - return setEquals(other.selectedAssets, selectedAssets) && - setEquals(other.lockedSelectionAssets, lockedSelectionAssets) && - other.forceEnable == forceEnable; - } - - @override - int get hashCode => selectedAssets.hashCode ^ lockedSelectionAssets.hashCode ^ forceEnable.hashCode; } class MultiSelectNotifier extends Notifier { diff --git a/mobile/lib/providers/upload_profile_image.provider.dart b/mobile/lib/providers/upload_profile_image.provider.dart index 5e7f7fc732..9b5e83622e 100644 --- a/mobile/lib/providers/upload_profile_image.provider.dart +++ b/mobile/lib/providers/upload_profile_image.provider.dart @@ -1,60 +1,18 @@ -import 'dart:convert'; - +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/utils/debug_print.dart'; +part 'upload_profile_image.provider.freezed.dart'; + enum UploadProfileStatus { idle, loading, success, failure } -class UploadProfileImageState { - // enum - final UploadProfileStatus status; - final String profileImagePath; - const UploadProfileImageState({required this.status, required this.profileImagePath}); - - UploadProfileImageState copyWith({UploadProfileStatus? status, String? profileImagePath}) { - return UploadProfileImageState( - status: status ?? this.status, - profileImagePath: profileImagePath ?? this.profileImagePath, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'status': status.index}); - result.addAll({'profileImagePath': profileImagePath}); - - return result; - } - - factory UploadProfileImageState.fromMap(Map map) { - return UploadProfileImageState( - status: UploadProfileStatus.values[map['status'] ?? 0], - profileImagePath: map['profileImagePath'] ?? '', - ); - } - - String toJson() => json.encode(toMap()); - - factory UploadProfileImageState.fromJson(String source) => UploadProfileImageState.fromMap(json.decode(source)); - - @override - String toString() => 'UploadProfileImageState(status: $status, profileImagePath: $profileImagePath)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is UploadProfileImageState && other.status == status && other.profileImagePath == profileImagePath; - } - - @override - int get hashCode => status.hashCode ^ profileImagePath.hashCode; +@freezed +abstract class UploadProfileImageState with _$UploadProfileImageState { + const factory UploadProfileImageState({required UploadProfileStatus status, required String profileImagePath}) = + _UploadProfileImageState; } class UploadProfileImageNotifier extends StateNotifier { diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index c6ed09360a..32531c354b 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -15,30 +17,11 @@ import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:socket_io_client/socket_io_client.dart'; -class WebsocketState { - final Socket? socket; - final bool isConnected; +part 'websocket.provider.freezed.dart'; - const WebsocketState({this.socket, required this.isConnected}); - - WebsocketState copyWith({Socket? socket, bool? isConnected}) { - return WebsocketState(socket: socket ?? this.socket, isConnected: isConnected ?? this.isConnected); - } - - @override - String toString() => 'WebsocketState(socket: $socket, isConnected: $isConnected)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is WebsocketState && other.socket == socket && other.isConnected == isConnected; - } - - @override - int get hashCode => socket.hashCode ^ isConnected.hashCode; +@freezed +abstract class WebsocketState with _$WebsocketState { + const factory WebsocketState({Socket? socket, required bool isConnected}) = _WebsocketState; } class WebsocketNotifier extends StateNotifier { @@ -56,14 +39,16 @@ class WebsocketNotifier extends StateNotifier { @override void dispose() { _batchDebouncer.dispose(); + state.socket?.dispose(); super.dispose(); } - /// Connects websocket to server unless already connected + /// Connects websocket to server unless an active socket already exists void connect() { - if (state.isConnected) { + if (state.socket?.active == true) { return; } + state.socket?.dispose(); final authenticationState = _ref.read(authProvider); if (authenticationState.isAuthenticated) { @@ -84,6 +69,8 @@ class WebsocketNotifier extends StateNotifier { .build(), ); + state = WebsocketState(isConnected: false, socket: socket); + socket.onConnect((_) { dPrint(() => "Established Websocket Connection"); state = WebsocketState(isConnected: true, socket: socket); @@ -91,12 +78,12 @@ class WebsocketNotifier extends StateNotifier { socket.onDisconnect((_) { dPrint(() => "Disconnect to Websocket Connection"); - state = const WebsocketState(isConnected: false, socket: null); + state = WebsocketState(isConnected: false, socket: socket); }); socket.on('error', (errorMessage) { _log.severe("Websocket Error - $errorMessage"); - state = const WebsocketState(isConnected: false, socket: null); + state = WebsocketState(isConnected: false, socket: socket); }); socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReadyV1); @@ -105,6 +92,11 @@ class WebsocketNotifier extends StateNotifier { socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2); socket.on('on_album_update', _handleRemoteChange); socket.on('on_asset_stack_update', _handleRemoteChange); + socket.on('on_asset_delete', _handleRemoteChange); + socket.on('on_asset_trash', _handleRemoteChange); + socket.on('on_asset_restore', _handleRemoteChange); + socket.on('on_asset_hidden', _handleRemoteChange); + socket.on('on_asset_update', _handleRemoteChange); socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_new_release', _handleReleaseUpdates); } catch (e) { @@ -187,7 +179,7 @@ class WebsocketNotifier extends StateNotifier { } void _handleRemoteChange(dynamic _) { - unawaited(_ref.read(backgroundSyncProvider).syncRemote()); + unawaited(_ref.read(backgroundSyncProvider).syncRemote(enqueue: true)); } void _handleSyncAssetEditReadyV2(dynamic data) { diff --git a/mobile/lib/repositories/biometric.repository.dart b/mobile/lib/repositories/biometric.repository.dart index 8bd5a85542..7da4bfe757 100644 --- a/mobile/lib/repositories/biometric.repository.dart +++ b/mobile/lib/repositories/biometric.repository.dart @@ -1,5 +1,5 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/auth/biometric_status.model.dart'; import 'package:local_auth/local_auth.dart'; @@ -19,6 +19,6 @@ class BiometricRepository { } Future authenticate(String? message) async { - return _localAuth.authenticate(localizedReason: message ?? 'please_auth_to_access'.tr()); + return _localAuth.authenticate(localizedReason: message ?? StaticTranslations.instance.please_auth_to_access); } } diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart index ed1ffd9e3d..8504487860 100644 --- a/mobile/lib/repositories/download.repository.dart +++ b/mobile/lib/repositories/download.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; +// ignore: dispose-provided-instances final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); class DownloadRepository { @@ -27,6 +28,8 @@ class DownloadRepository { void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; // #29900: `taskStatusCallback` is called before the DB has been updated, causing a race between the two Live Photo tasks @@ -48,6 +51,7 @@ class DownloadRepository { _downloader.registerCallbacks( group: kDownloadGroupLivePhoto, + taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); diff --git a/mobile/lib/repositories/permission.repository.dart b/mobile/lib/repositories/permission.repository.dart index e4b6d99f25..932be4a290 100644 --- a/mobile/lib/repositories/permission.repository.dart +++ b/mobile/lib/repositories/permission.repository.dart @@ -1,67 +1,83 @@ +import 'package:device_info_plus/device_info_plus.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/platform/permission_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:permission_handler/permission_handler.dart'; +import 'package:permission_handler/permission_handler.dart' as ph; final permissionRepositoryProvider = Provider((ref) { - return PermissionRepository(ref.watch(permissionApiProvider)); + return DevicePermissionRepository(ref.watch(permissionApiProvider)); }); -class PermissionRepository implements IPermissionRepository { +class DevicePermissionRepository { final PermissionApi _permissionApi; - const PermissionRepository(this._permissionApi); + const DevicePermissionRepository(this._permissionApi); - @override - Future hasLocationWhenInUsePermission() { - return Permission.locationWhenInUse.isGranted; + Future getStatus(DevicePermission permission) async => + (await permission.handler.status).toDevicePermissionStatus(); + + Future request(DevicePermission permission) async => + (await permission.handler.request()).toDevicePermissionStatus(); + + // TODO(shenlong): Move this to it's own device info repo + Future getAndroidSdkVersion() async { + if (CurrentPlatform.isIOS) { + throw UnsupportedError("This method is available only on Android"); + } + + final androidInfo = await DeviceInfoPlugin().androidInfo; + return androidInfo.version.sdkInt; } - @override + Future hasLocationWhenInUsePermission() => ph.Permission.locationWhenInUse.isGranted; + Future requestLocationWhenInUsePermission() async { - final result = await Permission.locationWhenInUse.request(); + final result = await ph.Permission.locationWhenInUse.request(); return result.isGranted; } - @override Future hasLocationAlwaysPermission() { - return Permission.locationAlways.isGranted; + return ph.Permission.locationAlways.isGranted; } - @override Future requestLocationAlwaysPermission() async { - final result = await Permission.locationAlways.request(); + final result = await ph.Permission.locationAlways.request(); return result.isGranted; } - @override Future openSettings() { - return openAppSettings(); + return ph.openAppSettings(); } - @override Future hasManageMediaPermission() { return _permissionApi.hasManageMediaPermission(); } - @override Future requestManageMediaPermission() { return _permissionApi.requestManageMediaPermission(); } - @override Future manageMediaPermission() { return _permissionApi.manageMediaPermission(); } } -abstract interface class IPermissionRepository { - Future hasLocationWhenInUsePermission(); - Future requestLocationWhenInUsePermission(); - Future hasLocationAlwaysPermission(); - Future requestLocationAlwaysPermission(); - Future openSettings(); - Future hasManageMediaPermission(); - Future requestManageMediaPermission(); - Future manageMediaPermission(); +extension on DevicePermission { + ph.Permission get handler => switch (this) { + .photos => ph.Permission.photos, + .videos => ph.Permission.videos, + .storage => ph.Permission.storage, + .mediaLocation => ph.Permission.accessMediaLocation, + }; +} + +extension on ph.PermissionStatus { + DevicePermissionStatus toDevicePermissionStatus() => switch (this) { + .granted => .granted, + .limited => .limited, + .permanentlyDenied => .permanentlyDenied, + _ => .denied, + }; } diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 158e3ac333..262868eea1 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -11,12 +11,7 @@ class PersonApiRepository extends ApiRepository { PersonApiRepository(this._api); - Future> getAll() async { - final dto = await checkNull(_api.getAllPeople()); - return dto.people.map(_toPerson).toList(); - } - - Future update(String id, {String? name, DateTime? birthday}) async { + Future update(String id, {String? name, DateTime? birthday}) async { final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); final dto = PersonUpdateDto( name: name == null ? const Optional.absent() : Optional.present(name), @@ -26,12 +21,6 @@ class PersonApiRepository extends ApiRepository { return _toPerson(response); } - static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( - birthDate: dto.birthDate, - id: dto.id, - isHidden: dto.isHidden, - name: dto.name, - thumbnailPath: dto.thumbnailPath, - updatedAt: dto.updatedAt.orElse(null), - ); + static Person _toPerson(PersonResponseDto dto) => + .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.orElse(null)); } diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index f126788008..90381cf49c 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -13,6 +13,8 @@ class AppNavigationObserver extends AutoRouterObserver { @override void didPush(Route route, Route? previousRoute) { + ref.invalidate(inLockedViewProvider); + ref.invalidate(isAssetViewerOpenProvider); unawaited( Future(() { ref.read(currentRouteNameProvider.notifier).state = route.settings.name; @@ -21,4 +23,29 @@ class AppNavigationObserver extends AutoRouterObserver { }), ); } + + @override + void didPop(Route route, Route? previousRoute) { + ref.invalidate(inLockedViewProvider); + ref.invalidate(isAssetViewerOpenProvider); + } +} + +/// Tracks routes that are undergoing a pop transition +class TransitioningRouteObserver extends NavigatorObserver { + int _transitioningRoutes = 0; + + /// Whether a "popping" route is still on screen + bool get hasTransitioningRoute => _transitioningRoutes > 0; + + @override + void didPop(Route route, Route? previousRoute) { + if (route is! TransitionRoute) { + return; + } + + _transitioningRoutes += 1; + // Transition completed and route disposed + unawaited(route.completed.whenComplete(() => _transitioningRoutes -= 1)); + } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index ea33e4ba25..1d6455782c 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -71,7 +71,6 @@ import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.pa import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/duplicate_guard.dart'; import 'package:immich_mobile/routing/locked_guard.dart'; @@ -84,10 +83,10 @@ import 'package:maplibre_gl/maplibre_gl.dart'; part 'router.gr.dart'; final appRouterProvider = Provider( + // ignore: dispose-provided-instances (ref) => AppRouter( ref.watch(apiServiceProvider), ref.watch(authServiceProvider), - ref.watch(galleryPermissionNotifier.notifier), ref.watch(secureStorageServiceProvider), ref.watch(localAuthServiceProvider), ), @@ -102,7 +101,6 @@ class AppRouter extends RootStackRouter { AppRouter( ApiService apiService, AuthService authService, - GalleryPermissionNotifier galleryPermissionNotifier, SecureStorageService secureStorageService, LocalAuthService localAuthService, ) { @@ -156,7 +154,7 @@ class AppRouter extends RootStackRouter { customRouteBuilder: (context, child, page) => PageRouteBuilder( fullscreenDialog: page.fullscreenDialog, settings: page, - pageBuilder: (_, __, ___) => child, + pageBuilder: (_, _, _) => child, opaque: false, transitionsBuilder: TransitionsBuilders.fadeIn, ), diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index ec9b7cd446..fcfb96d988 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; final actionServiceProvider = Provider( - (ref) => ActionService(ref.watch(assetApiRepositoryProvider), ref.watch(remoteAssetRepositoryProvider)), + (ref) => ActionService(ref.watch(assetApiRepositoryProvider), ref.watch(driftProvider).remoteAssetRepository), ); class ActionService { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index f5e4c5c33e..d1aec9cc86 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -113,12 +113,10 @@ class ApiService { } Future _isEndpointAvailable(String serverUrl) async { - if (!serverUrl.endsWith('/api')) { - serverUrl += '/api'; - } + final endpoint = serverUrl.endsWith('/api') ? serverUrl : '$serverUrl/api'; try { - setEndpoint(serverUrl); + setEndpoint(endpoint); await serverInfoApi.pingServer().timeout(const Duration(seconds: 5)); } on TimeoutException catch (_) { return false; diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index 5312107f6c..b526cc4b4f 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; @@ -15,7 +16,7 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; @@ -25,12 +26,15 @@ import 'package:logging/logging.dart'; import 'package:openapi/api.dart' as api; import 'package:path/path.dart' as p; +part 'background_upload.service.freezed.dart'; + final backgroundUploadServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); final service = BackgroundUploadService( ref.watch(uploadRepositoryProvider), ref.watch(storageRepositoryProvider), - ref.watch(localAssetRepository), - ref.watch(backupRepositoryProvider), + db.localAssetRepository, + db.backupRepository, ref.watch(assetMediaRepositoryProvider), ); @@ -39,20 +43,15 @@ final backgroundUploadServiceProvider = Provider((ref) { }); /// Metadata for upload tasks to track live photo handling -class UploadTaskMetadata { - final String localAssetId; - final bool isLivePhotos; - final String livePhotoVideoId; +@Freezed(fromJson: false, toJson: false) +abstract class UploadTaskMetadata with _$UploadTaskMetadata { + const UploadTaskMetadata._(); - const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId}); - - UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) { - return UploadTaskMetadata( - localAssetId: localAssetId ?? this.localAssetId, - isLivePhotos: isLivePhotos ?? this.isLivePhotos, - livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, - ); - } + const factory UploadTaskMetadata({ + required String localAssetId, + required bool isLivePhotos, + required String livePhotoVideoId, + }) = _UploadTaskMetadata; Map toMap() { return { @@ -70,28 +69,10 @@ class UploadTaskMetadata { ); } - String toJson() => json.encode(toMap()); - factory UploadTaskMetadata.fromJson(String source) => UploadTaskMetadata.fromMap(json.decode(source) as Map); - @override - String toString() => - 'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)'; - - @override - bool operator ==(covariant UploadTaskMetadata other) { - if (identical(this, other)) { - return true; - } - - return other.localAssetId == localAssetId && - other.isLivePhotos == isLivePhotos && - other.livePhotoVideoId == livePhotoVideoId; - } - - @override - int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode; + String toJson() => json.encode(toMap()); } /// Service for handling background uploads using iOS URLSession (background_downloader) @@ -112,8 +93,8 @@ class BackgroundUploadService { final UploadRepository _uploadRepository; final StorageRepository _storageRepository; - final DriftLocalAssetRepository _localAssetRepository; - final DriftBackupRepository _backupRepository; + final LocalAssetRepository _localAssetRepository; + final BackupRepository _backupRepository; final AssetMediaRepository _assetMediaRepository; final Logger _logger = Logger('BackgroundUploadService'); @@ -398,7 +379,7 @@ class BackgroundUploadService { 'fileModifiedAt': modifiedAt.toUtc().toIso8601String(), 'isFavorite': isFavorite?.toString() ?? 'false', 'duration': '0', - if (fields != null) ...fields, + ...?fields, if (CurrentPlatform.isIOS && cloudId != null) 'metadata': jsonEncode([ RemoteAssetMetadataItem( diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart index fca5584859..06cda2cf0d 100644 --- a/mobile/lib/services/cleanup.service.dart +++ b/mobile/lib/services/cleanup.service.dart @@ -2,17 +2,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; final cleanupServiceProvider = Provider((ref) { - return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider)); + return CleanupService(ref.watch(driftProvider).localAssetRepository, ref.watch(assetMediaRepositoryProvider)); }); class CleanupService { static final int _deleteBatchSize = CurrentPlatform.isAndroid ? 2000 : 10000; - final DriftLocalAssetRepository _localAssetRepository; + final LocalAssetRepository _localAssetRepository; final AssetMediaRepository _assetMediaRepository; const CleanupService(this._localAssetRepository, this._assetMediaRepository); @@ -46,7 +46,7 @@ class CleanupService { final deletedIds = await _assetMediaRepository.deleteAll(batch); if (deletedIds.isNotEmpty) { - await _localAssetRepository.delete(deletedIds); + await _localAssetRepository.deleteAssets(deletedIds); deletedCount += deletedIds.length; } } diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index d5947ceb8f..617a5b6132 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider; -import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -21,7 +21,7 @@ final deepLinkServiceProvider = Provider( ref.watch(timelineFactoryProvider), ref.watch(beta_asset_provider.assetServiceProvider), ref.watch(remoteAlbumServiceProvider), - ref.watch(driftMemoryServiceProvider), + DriftMemoryService(ref.watch(driftProvider).memoryRepository), ref.watch(driftPeopleServiceProvider), ref.watch(currentUserProvider), ), diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart index f38b20cc21..09a56f0ce9 100644 --- a/mobile/lib/services/download.service.dart +++ b/mobile/lib/services/download.service.dart @@ -22,6 +22,7 @@ class DownloadService { final Logger _log = Logger("DownloadService"); void Function(TaskStatusUpdate)? onImageDownloadStatus; void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; void Function(TaskProgressUpdate)? onTaskProgress; /// Active Live Photo IDs undergoing saving @@ -30,6 +31,7 @@ class DownloadService { DownloadService(this._fileMediaRepository, this._downloadRepository) { _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback; _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback; + _downloadRepository.onLivePhotoDownloadStatus = _onLivePhotoDownloadCallback; _downloadRepository.onTaskProgress = _onTaskProgressCallback; _downloadRepository.onLivePhotoRecordComplete = _onLivePhotoRecordComplete; @@ -65,6 +67,10 @@ class DownloadService { onVideoDownloadStatus?.call(update); } + void _onLivePhotoDownloadCallback(TaskStatusUpdate update) { + onLivePhotoDownloadStatus?.call(update); + } + Future _onLivePhotoRecordComplete(TaskRecord record) async { final livePhotosId = LivePhotosMetadata.fromJson(record.task.metaData).id; await _saveLivePhotos(livePhotosId); diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 36d3975a26..ac42e2482f 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -10,11 +10,12 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; @@ -35,10 +36,11 @@ class UploadCallbacks { } final foregroundUploadServiceProvider = Provider((ref) { + // ignore: dispose-provided-instances return ForegroundUploadService( ref.watch(uploadRepositoryProvider), ref.watch(storageRepositoryProvider), - ref.watch(backupRepositoryProvider), + ref.watch(driftProvider).backupRepository, ref.watch(connectivityApiProvider), ref.watch(assetMediaRepositoryProvider), ); @@ -60,7 +62,7 @@ class ForegroundUploadService { final UploadRepository _uploadRepository; final StorageRepository _storageRepository; - final DriftBackupRepository _backupRepository; + final BackupRepository _backupRepository; final ConnectivityApi _connectivityApi; final AssetMediaRepository _assetMediaRepository; final Logger _logger = Logger('ForegroundUploadService'); @@ -240,16 +242,17 @@ class ForegroundUploadService { Completer? cancelToken, { required UploadCallbacks callbacks, }) async { + final t = StaticTranslations.instance; + final assetNotFoundOnDevice = CurrentPlatform.isAndroid + ? t.asset_not_found_on_device_android + : t.asset_not_found_on_device_ios; File? file; File? livePhotoFile; try { final entity = await _storageRepository.getAssetEntityForAsset(asset); if (entity == null) { - callbacks.onError?.call( - asset.localId!, - CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(), - ); + callbacks.onError?.call(asset.localId!, assetNotFoundOnDevice); return; } @@ -283,10 +286,7 @@ class ForegroundUploadService { file = await _storageRepository.getFileForAsset(asset.id); if (file == null) { _logger.warning("Failed to get file ${asset.id} - ${asset.name}"); - callbacks.onError?.call( - asset.localId!, - CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(), - ); + callbacks.onError?.call(asset.localId!, assetNotFoundOnDevice); return; } @@ -295,17 +295,14 @@ class ForegroundUploadService { livePhotoFile = await _storageRepository.getMotionFileForAsset(asset); if (livePhotoFile == null) { _logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}"); - callbacks.onError?.call( - asset.localId!, - CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(), - ); + callbacks.onError?.call(asset.localId!, assetNotFoundOnDevice); } } } if (file == null) { _logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}"); - callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t()); + callbacks.onError?.call(asset.localId!, t.asset_not_found_on_icloud); return; } @@ -383,7 +380,6 @@ class ForegroundUploadService { if (result.isSuccess && result.remoteAssetId != null) { callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!); } else if (result.isCancelled) { - _logger.warning(() => "Backup was cancelled by the user"); shouldAbortUpload = true; } else if (result.errorMessage != null) { _logger.severe( diff --git a/mobile/lib/services/network.service.dart b/mobile/lib/services/network.service.dart index 8622400e7a..e31ecb617e 100644 --- a/mobile/lib/services/network.service.dart +++ b/mobile/lib/services/network.service.dart @@ -8,7 +8,7 @@ final networkServiceProvider = Provider((ref) { class NetworkService { final NetworkRepository _repository; - final IPermissionRepository _permissionRepository; + final DevicePermissionRepository _permissionRepository; const NetworkService(this._repository, this._permissionRepository); diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart deleted file mode 100644 index 0d589ea71d..0000000000 --- a/mobile/lib/services/person.service.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/repositories/person_api.repository.dart'; -import 'package:logging/logging.dart'; - -final personServiceProvider = Provider.autoDispose( - (ref) => PersonService(ref.watch(personApiRepositoryProvider)), -); - -class PersonService { - final Logger _log = Logger("PersonService"); - final PersonApiRepository _personApiRepository; - PersonService(this._personApiRepository); - - Future> getAllPeople() async { - try { - return await _personApiRepository.getAll(); - } catch (error, stack) { - _log.severe("Error while fetching curated people", error, stack); - return []; - } - } - - Future updateName(String id, String name) async { - try { - return await _personApiRepository.update(id, name: name); - } catch (error, stack) { - _log.severe("Error while updating person name", error, stack); - } - return null; - } -} diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 7bda1bdc13..94296f5293 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:logging/logging.dart'; @@ -19,13 +19,13 @@ class ViewIntentResolvedAsset { final viewIntentAssetResolverProvider = Provider( (ref) => ViewIntentAssetResolver( - localAssetRepository: ref.read(localAssetRepository), + localAssetRepository: ref.read(driftProvider).localAssetRepository, timelineFactory: ref.read(timelineFactoryProvider), ), ); class ViewIntentAssetResolver { - final DriftLocalAssetRepository _localAssetRepository; + final LocalAssetRepository _localAssetRepository; final TimelineFactory _timelineFactory; static final Logger _logger = Logger('ViewIntentAssetResolver'); diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 28a9442dc7..4e0a5efff5 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -1,5 +1,4 @@ import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; @@ -7,6 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; @@ -203,13 +203,13 @@ enum ActionButtonType { ), ActionButtonType.setProfilePicture => ActionMenuItem(action: SetProfilePictureAction(asset: context.asset)), ActionButtonType.openInfo => BaseActionButton( - label: 'info'.tr(), + label: StaticTranslations.instance.info, iconData: Icons.info_outline, menuItem: true, onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()), ), ActionButtonType.viewInTimeline => BaseActionButton( - label: 'view_in_timeline'.tr(), + label: StaticTranslations.instance.view_in_timeline, iconData: Icons.image_search, iconOnly: iconOnly, menuItem: menuItem, diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 37ad748a57..e6116afe53 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -2,7 +2,7 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; @@ -15,31 +15,33 @@ import 'package:photo_manager/photo_manager.dart'; import 'package:sqlite3/common.dart'; void configureFileDownloaderNotifications() { + final t = StaticTranslations.instance; + FileDownloader().configureNotificationForGroup( kDownloadGroupImage, - running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'), - complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'), + running: TaskNotification(t.downloading_media, '${t.file_name_text}: {filename}'), + complete: TaskNotification(t.download_finished, '${t.file_name_text}: {filename}'), progressBar: true, ); FileDownloader().configureNotificationForGroup( kDownloadGroupVideo, - running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'), - complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'), + running: TaskNotification(t.downloading_media, '${t.file_name_text}: {filename}'), + complete: TaskNotification(t.download_finished, '${t.file_name_text}: {filename}'), progressBar: true, ); FileDownloader().configureNotificationForGroup( kManualUploadGroup, - running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()), - complete: TaskNotification('upload_finished'.t(), 'backup_background_service_complete_notification'.t()), + running: TaskNotification(t.uploading_media, t.backup_background_service_in_progress_notification), + complete: TaskNotification(t.upload_finished, t.backup_background_service_complete_notification), groupNotificationId: kManualUploadGroup, ); FileDownloader().configureNotificationForGroup( kBackupGroup, - running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()), - complete: TaskNotification('upload_finished'.t(), 'backup_background_service_complete_notification'.t()), + running: TaskNotification(t.uploading_media, t.backup_background_service_in_progress_notification), + complete: TaskNotification(t.upload_finished, t.backup_background_service_complete_notification), groupNotificationId: kBackupGroup, ); } @@ -49,7 +51,7 @@ abstract final class Bootstrap { await configureSqliteCache(); final (db, updatePool) = await openSqliteConnectionWithUpdatePool(name: 'immich'); final drift = Drift.sqlite(db, updatePool); - final DriftStoreRepository storeRepo = DriftStoreRepository(drift); + final StoreRepository storeRepo = StoreRepository(drift); await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates); diff --git a/mobile/lib/utils/hooks/app_settings_update_hook.dart b/mobile/lib/utils/hooks/app_settings_update_hook.dart index 9d7cc4b064..6b7ba357f3 100644 --- a/mobile/lib/utils/hooks/app_settings_update_hook.dart +++ b/mobile/lib/utils/hooks/app_settings_update_hook.dart @@ -9,7 +9,7 @@ ValueNotifier useAppSettingsState(AppSettingsEnum key) { final notifier = useState(Store.get(key.storeKey, key.defaultValue)); // Listen to changes to the notifier and update app settings - useValueChanged(notifier.value, (_, __) => unawaited(Store.put(key.storeKey, notifier.value))); + useValueChanged(notifier.value, (_, _) => unawaited(Store.put(key.storeKey, notifier.value))); return notifier; } diff --git a/mobile/lib/utils/map_utils.dart b/mobile/lib/utils/map_utils.dart index d5e6f957a5..2a89b63422 100644 --- a/mobile/lib/utils/map_utils.dart +++ b/mobile/lib/utils/map_utils.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:logging/logging.dart'; @@ -121,10 +121,10 @@ class MapUtils { class _LocationServiceDisabledDialog extends ConfirmDialog { _LocationServiceDisabledDialog() : super( - title: 'map_location_service_disabled_title'.tr(), - content: 'map_location_service_disabled_content'.tr(), - cancel: 'cancel'.tr(), - ok: 'yes'.tr(), + title: StaticTranslations.instance.map_location_service_disabled_title, + content: StaticTranslations.instance.map_location_service_disabled_content, + cancel: StaticTranslations.instance.cancel, + ok: StaticTranslations.instance.yes, onOk: () async { await Geolocator.openLocationSettings(); }, @@ -134,10 +134,10 @@ class _LocationServiceDisabledDialog extends ConfirmDialog { class _LocationPermissionDisabledDialog extends ConfirmDialog { _LocationPermissionDisabledDialog() : super( - title: 'map_no_location_permission_title'.tr(), - content: 'map_no_location_permission_content'.tr(), - cancel: 'cancel'.tr(), - ok: 'yes'.tr(), + title: StaticTranslations.instance.map_no_location_permission_title, + content: StaticTranslations.instance.map_no_location_permission_content, + cancel: StaticTranslations.instance.cancel, + ok: StaticTranslations.instance.yes, onOk: () {}, ); } diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index e92b2afd12..9df2eea462 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -41,6 +41,7 @@ final Map> openApiPatches = { 'SyncAssetV1': {'isEdited': false}, 'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false}, 'MemoriesResponse': {'duration': 5}, + 'WorkflowResponseDto': {'logging': false}, }; void upgradeDto(dynamic value, String targetType) { diff --git a/mobile/lib/utils/people.utils.dart b/mobile/lib/utils/people.utils.dart index 18ae8a4528..bd2b607e0b 100644 --- a/mobile/lib/utils/people.utils.dart +++ b/mobile/lib/utils/people.utils.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_birthday_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; @@ -8,12 +8,13 @@ String formatAge(DateTime birthDate, DateTime referenceDate) { final int ageInYears = _calculateAge(birthDate, referenceDate); final int ageInMonths = _calculateAgeInMonths(birthDate, referenceDate); + final t = StaticTranslations.instance; if (ageInMonths <= 11) { - return "person_age_months".t(args: {'months': ageInMonths.toString()}); + return t.person_age_months(months: ageInMonths); } else if (ageInMonths > 12 && ageInMonths <= 23) { - return "person_age_year_months".t(args: {'months': (ageInMonths - 12).toString()}); + return t.person_age_year_months(months: ageInMonths - 12); } else { - return "person_age_years".t(args: {'years': ageInYears.toString()}); + return t.person_age_years(years: ageInYears); } } @@ -33,7 +34,7 @@ int _calculateAgeInMonths(DateTime birthDate, DateTime referenceDate) { (referenceDate.day < birthDate.day ? 1 : 0); } -Future showNameEditModal(BuildContext context, DriftPerson person) { +Future showNameEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, @@ -43,7 +44,7 @@ Future showNameEditModal(BuildContext context, DriftPerson person) { ); } -Future showBirthdayEditModal(BuildContext context, DriftPerson person) { +Future showBirthdayEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart index fcaff8bfc3..5431420741 100644 --- a/mobile/lib/widgets/activities/comment_bubble.dart +++ b/mobile/lib/widgets/activities/comment_bubble.dart @@ -28,7 +28,7 @@ class CommentBubble extends ConsumerWidget { final isLike = activity.type == ActivityType.like; final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer; - final activityNotifier = ref.read( + final activityNotifier = ref.watch( albumActivityProvider((album.id, isAssetActivity ? activity.assetId : null)).notifier, ); diff --git a/mobile/lib/widgets/activities/dismissible_activity.dart b/mobile/lib/widgets/activities/dismissible_activity.dart index c056f5ee35..f1305ba3aa 100644 --- a/mobile/lib/widgets/activities/dismissible_activity.dart +++ b/mobile/lib/widgets/activities/dismissible_activity.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; class DismissibleActivity extends StatelessWidget { @@ -23,9 +24,9 @@ class DismissibleActivity extends StatelessWidget { context: context, builder: (context) => ConfirmDialog( onOk: () {}, - title: "shared_album_activity_remove_title", - content: "shared_album_activity_remove_content", - ok: "delete", + title: context.t.shared_album_activity_remove_title, + content: context.t.shared_album_activity_remove_content, + ok: context.t.delete, ), ) : Future.value(false), diff --git a/mobile/lib/widgets/asset_grid/delete_dialog.dart b/mobile/lib/widgets/asset_grid/delete_dialog.dart index ff5aac617a..9b0367a463 100644 --- a/mobile/lib/widgets/asset_grid/delete_dialog.dart +++ b/mobile/lib/widgets/asset_grid/delete_dialog.dart @@ -1,69 +1,69 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; - -class DeleteLocalOnlyDialog extends StatelessWidget { - final void Function(bool onlyMerged) onDeleteLocal; - - const DeleteLocalOnlyDialog({super.key, required this.onDeleteLocal}); - - @override - Widget build(BuildContext context) { - void onDeleteBackedUpOnly() { - context.pop(true); - onDeleteLocal(true); - } - - void onForceDelete() { - context.pop(false); - onDeleteLocal(false); - } - - return AlertDialog( - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), - title: const Text("delete_dialog_title").tr(), - content: const Text("delete_dialog_alert_local_non_backed_up").tr(), - actions: [ - SizedBox( - width: double.infinity, - height: 48, - child: FilledButton( - onPressed: () => context.pop(), - style: FilledButton.styleFrom( - backgroundColor: context.colorScheme.surfaceDim, - foregroundColor: context.primaryColor, - ), - child: const Text("cancel", style: TextStyle(fontWeight: FontWeight.bold)).tr(), - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - height: 48, - - child: FilledButton( - onPressed: onDeleteBackedUpOnly, - style: FilledButton.styleFrom( - backgroundColor: context.colorScheme.errorContainer, - foregroundColor: context.colorScheme.onErrorContainer, - ), - child: const Text( - "delete_local_dialog_ok_backed_up_only", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - height: 48, - child: FilledButton( - onPressed: onForceDelete, - style: FilledButton.styleFrom(backgroundColor: Colors.red[400], foregroundColor: Colors.white), - child: const Text("delete_local_dialog_ok_force", style: TextStyle(fontWeight: FontWeight.bold)).tr(), - ), - ), - ], - ); - } -} +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; + +class DeleteLocalOnlyDialog extends StatelessWidget { + final void Function(bool onlyMerged) onDeleteLocal; + + const DeleteLocalOnlyDialog({super.key, required this.onDeleteLocal}); + + @override + Widget build(BuildContext context) { + void onDeleteBackedUpOnly() { + context.pop(true); + onDeleteLocal(true); + } + + void onForceDelete() { + context.pop(false); + onDeleteLocal(false); + } + + return AlertDialog( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + title: Text(context.t.delete_dialog_title), + content: Text(context.t.delete_dialog_alert_local_non_backed_up), + actions: [ + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + onPressed: () => context.pop(), + style: FilledButton.styleFrom( + backgroundColor: context.colorScheme.surfaceDim, + foregroundColor: context.primaryColor, + ), + child: Text(context.t.cancel, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + height: 48, + + child: FilledButton( + onPressed: onDeleteBackedUpOnly, + style: FilledButton.styleFrom( + backgroundColor: context.colorScheme.errorContainer, + foregroundColor: context.colorScheme.onErrorContainer, + ), + child: Text( + context.t.delete_local_dialog_ok_backed_up_only, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + onPressed: onForceDelete, + style: FilledButton.styleFrom(backgroundColor: Colors.red[400], foregroundColor: Colors.white), + child: Text(context.t.delete_local_dialog_ok_force, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart index d406f29a22..70b1b3d3e5 100644 --- a/mobile/lib/widgets/asset_viewer/cast_dialog.dart +++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; @@ -23,21 +23,21 @@ class CastDialog extends ConsumerWidget { } return AlertDialog( - title: const Text("cast", style: TextStyle(fontWeight: FontWeight.bold)).tr(), + title: Text(context.t.cast, style: const TextStyle(fontWeight: FontWeight.bold)), content: SizedBox( width: 250, height: 250, child: FutureBuilder>( - future: ref.read(castProvider.notifier).getDevices(), + future: ref.watch(castProvider.notifier).getDevices(), builder: (context, snapshot) { if (snapshot.hasError) { - return Text('error_saving_image'.tr(args: [snapshot.error.toString()])); + return Text(context.t.error_saving_image(error: snapshot.error.toString())); } else if (!snapshot.hasData) { return const SizedBox(height: 48, child: Center(child: CircularProgressIndicator())); } if (snapshot.data!.isEmpty) { - return const Text('no_cast_devices_found').tr(); + return Text(context.t.no_cast_devices_found); } final devices = snapshot.data!; @@ -47,12 +47,12 @@ class CastDialog extends ConsumerWidget { final List sectionedList = []; if (connected.isNotEmpty) { - sectionedList.add("connected_device"); + sectionedList.add(context.t.connected_device); sectionedList.addAll(connected); } if (others.isNotEmpty) { - sectionedList.add("discovered_devices"); + sectionedList.add(context.t.discovered_devices); sectionedList.addAll(others); } @@ -66,7 +66,7 @@ class CastDialog extends ConsumerWidget { // It's a section header return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text(item, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)).tr(), + child: Text(item, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), ); } else { final (deviceName, type, deviceObj) = item as (String, CastDestinationType, dynamic); @@ -110,16 +110,16 @@ class CastDialog extends ConsumerWidget { TextButton( onPressed: () => ref.read(castProvider.notifier).disconnect(), child: Text( - "stop_casting", + context.t.stop_casting, style: TextStyle(color: context.colorScheme.secondary, fontWeight: FontWeight.bold), - ).tr(), + ), ), TextButton( onPressed: () => context.pop(), child: Text( - "close", + context.t.close, style: TextStyle(color: context.colorScheme.primary, fontWeight: FontWeight.bold), - ).tr(), + ), ), ], ); diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index f39077a522..b5678b8d9f 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -97,9 +97,9 @@ class _VideoControlsState extends ConsumerState { _hideTimer.reset(); } }); - ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset()); + ref.listen(_provider.select((v) => v.status), (_, _) => _hideTimer.reset()); - final notifier = ref.read(_provider.notifier); + final notifier = ref.watch(_provider.notifier); final isLoaded = duration != Duration.zero; return Padding( diff --git a/mobile/lib/widgets/backup/backup_info_card.dart b/mobile/lib/widgets/backup/backup_info_card.dart index 7911679577..4efd940e36 100644 --- a/mobile/lib/widgets/backup/backup_info_card.dart +++ b/mobile/lib/widgets/backup/backup_info_card.dart @@ -1,8 +1,7 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class BackupInfoCard extends StatelessWidget { final String title; @@ -73,11 +72,11 @@ class BackupInfoCard extends StatelessWidget { ], ), Text( - "backup_info_card_assets", + context.t.backup_info_card_assets, style: context.textTheme.labelLarge?.copyWith( color: context.colorScheme.onSurface.withAlpha(isLoading ? 50 : 255), ), - ).tr(), + ), ], ), ), @@ -93,7 +92,7 @@ class BackupInfoCard extends StatelessWidget { ), onTap: onTap, title: Text( - "view_details".t(context: context), + context.t.view_details, style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)), ), trailing: Icon(Icons.arrow_forward_ios, size: 16, color: context.colorScheme.onSurfaceVariant), diff --git a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart index 999b64e9de..41a215fb99 100644 --- a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart @@ -53,7 +53,7 @@ class DriftAlbumInfoListTile extends HookConsumerWidget { return GestureDetector( onDoubleTap: () { - ref.watch(hapticFeedbackProvider.notifier).selectionClick(); + ref.read(hapticFeedbackProvider.notifier).selectionClick(); if (isExcluded) { unawaited(ref.read(backupAlbumProvider.notifier).deselectAlbum(album)); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 53c1eb1af9..fffa6746f6 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; @@ -78,20 +78,24 @@ class ImmichAppBarDialog extends HookConsumerWidget { title: Text( text, style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)), - ).tr(), + ), onTap: onTap, trailing: trailing, ); } ListTile buildSettingButton() { - return buildActionButton(Icons.settings_outlined, "settings", () => context.pushRoute(const SettingsRoute())); + return buildActionButton( + Icons.settings_outlined, + context.t.settings, + () => context.pushRoute(const SettingsRoute()), + ); } ListTile buildFreeUpSpaceButton() { return buildActionButton( Icons.cleaning_services_outlined, - "free_up_space", + context.t.free_up_space, () => context.pushRoute(SettingsSubRoute(section: SettingSection.freeUpSpace)), ); } @@ -99,7 +103,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { ListTile buildAppLogButton() { return buildActionButton( Icons.assignment_outlined, - "profile_drawer_app_logs", + context.t.profile_drawer_app_logs, () => context.pushRoute(const AppLogRoute()), ); } @@ -107,7 +111,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { ListTile buildSignOutButton() { return buildActionButton( Icons.logout_rounded, - "sign_out", + context.t.sign_out, () async { if (isLoggingOut.value) { return; @@ -118,12 +122,15 @@ class ImmichAppBarDialog extends HookConsumerWidget { context: context, builder: (BuildContext ctx) { return ConfirmDialog( - title: "app_bar_signout_dialog_title", - content: "app_bar_signout_dialog_content", - ok: "yes", + title: context.t.app_bar_signout_dialog_title, + content: context.t.app_bar_signout_dialog_content, + ok: context.t.yes, onOk: () async { isLoggingOut.value = true; await ref.read(authProvider.notifier).logout().whenComplete(() => isLoggingOut.value = false); + if (!context.mounted) { + return; + } ref.read(websocketProvider.notifier).disconnect(); if (!context.mounted) { @@ -160,16 +167,16 @@ class ImmichAppBarDialog extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: 12, children: [ - Text("backup_controller_page_server_storage".tr(), style: context.textTheme.labelLarge), + Text(context.t.backup_controller_page_server_storage, style: context.textTheme.labelLarge), LinearProgressIndicator( minHeight: 10.0, value: percentage, borderRadius: const BorderRadius.all(Radius.circular(10.0)), ), Text( - 'backup_controller_page_storage_format', + context.t.backup_controller_page_storage_format(used: usedDiskSpace, total: totalDiskSpace), style: context.textTheme.bodySmall, - ).tr(namedArgs: {'used': usedDiskSpace, 'total': totalDiskSpace}), + ), ], ), ); @@ -186,7 +193,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { ContextHelper(context).pop(); unawaited(launchUrl(Uri.parse('https://docs.immich.app'), mode: LaunchMode.externalApplication)); }, - child: Text("documentation", style: context.textTheme.bodySmall).tr(), + child: Text(context.t.documentation, style: context.textTheme.bodySmall), ), const SizedBox(width: 20, child: Text("•", textAlign: TextAlign.center)), InkWell( @@ -196,7 +203,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { launchUrl(Uri.parse('https://github.com/immich-app/immich'), mode: LaunchMode.externalApplication), ); }, - child: Text("profile_drawer_github", style: context.textTheme.bodySmall).tr(), + child: Text(context.t.profile_drawer_github, style: context.textTheme.bodySmall), ), const SizedBox(width: 20, child: Text("•", textAlign: TextAlign.center)), InkWell( @@ -216,7 +223,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { applicationVersion: packageInfo.version, ); }, - child: Text("licenses", style: context.textTheme.bodySmall).tr(), + child: Text(context.t.licenses, style: context.textTheme.bodySmall), ), ], ), @@ -234,10 +241,10 @@ class ImmichAppBarDialog extends HookConsumerWidget { minLeadingWidth: 20, tileColor: theme.primaryColor.withAlpha(80), title: Text( - "profile_drawer_readonly_mode", + context.t.profile_drawer_readonly_mode, style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)), textAlign: TextAlign.center, - ).tr(), + ), ), ); } diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index c963564c69..bd8e883ef0 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; @@ -45,12 +45,12 @@ class AppBarProfileInfoBox extends HookConsumerWidget { Future pickUserProfileImage() async { final XFile? image = await ImagePicker().pickImage(source: ImageSource.gallery, maxHeight: 1024, maxWidth: 1024); - if (image != null) { - final success = await ref.watch(uploadProfileImageProvider.notifier).upload(image); + if (image != null && context.mounted) { + final success = await ref.read(uploadProfileImageProvider.notifier).upload(image); - if (success) { + if (success && context.mounted) { final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath; - ref.watch(authProvider.notifier).updateUserProfileImagePath(profileImagePath); + ref.read(authProvider.notifier).updateUserProfileImagePath(profileImagePath); if (user != null) { unawaited(ref.read(currentUserProvider.notifier).refresh()); } @@ -61,14 +61,14 @@ class AppBarProfileInfoBox extends HookConsumerWidget { } void toggleReadonlyMode() { - final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + final isReadonlyModeEnabled = ref.read(readonlyModeProvider); ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); context.scaffoldMessenger.showSnackBar( SnackBar( duration: const Duration(seconds: 2), content: Text( - (isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(), + isReadonlyModeEnabled ? context.t.readonly_mode_disabled : context.t.readonly_mode_enabled, style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), ), ), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart index 348e3aa14c..901ffb2496 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -46,20 +46,20 @@ class AppBarServerInfo extends HookConsumerWidget { children: [ if (showVersionWarning) ...[const ServerUpdateNotification(), divider], _ServerInfoItem( - label: "server_info_box_app_version".tr(), + label: context.t.server_info_box_app_version, text: "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}", ), divider, _ServerInfoItem( - label: "server_version".tr(), + label: context.t.server_version, text: serverInfoState.serverVersion.major > 0 ? "${serverInfoState.serverVersion}" : "--", ), divider, - _ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true), + _ServerInfoItem(label: context.t.server_info_box_server_url, text: getServerUrl() ?? '--', tooltip: true), if (serverInfoState.latestVersion != null) ...[ divider, _ServerInfoItem( - label: "latest_version".tr(), + label: context.t.latest_version, text: serverInfoState.latestVersion!.major > 0 ? "${serverInfoState.latestVersion!}" : "--", tooltip: true, icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate diff --git a/mobile/lib/widgets/common/app_bar_dialog/server_update_notification.dart b/mobile/lib/widgets/common/app_bar_dialog/server_update_notification.dart index 806ac87c19..8261f71ad8 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/server_update_notification.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/server_update_notification.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'dart:io'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -76,8 +76,8 @@ class ServerUpdateNotification extends HookConsumerWidget { tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: serverInfoState.versionStatus == VersionStatus.clientOutOfDate - ? Text("action_common_update".tr(context: context)) - : Text("view".tr()), + ? Text(context.t.action_common_update) + : Text(context.t.view), ), ], ], diff --git a/mobile/lib/widgets/common/confirm_dialog.dart b/mobile/lib/widgets/common/confirm_dialog.dart index 9c3f4344c6..74b7f4a04c 100644 --- a/mobile/lib/widgets/common/confirm_dialog.dart +++ b/mobile/lib/widgets/common/confirm_dialog.dart @@ -1,22 +1,15 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class ConfirmDialog extends StatelessWidget { final Function? onOk; final String title; final String content; - final String cancel; - final String ok; + final String? cancel; + final String? ok; - const ConfirmDialog({ - super.key, - this.onOk, - required this.title, - required this.content, - this.cancel = "cancel", - this.ok = "backup_controller_page_background_battery_info_ok", - }); + const ConfirmDialog({super.key, this.onOk, required this.title, required this.content, this.cancel, this.ok}); @override Widget build(BuildContext context) { @@ -27,22 +20,22 @@ class ConfirmDialog extends StatelessWidget { return AlertDialog( shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), - title: Text(title).tr(), - content: Text(content).tr(), + title: Text(title), + content: Text(content), actions: [ TextButton( onPressed: () => context.pop(false), child: Text( - cancel, + cancel ?? context.t.cancel, style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold), - ).tr(), + ), ), TextButton( onPressed: onOkPressed, child: Text( - ok, + ok ?? context.t.backup_controller_page_background_battery_info_ok, style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.bold), - ).tr(), + ), ), ], ); diff --git a/mobile/lib/widgets/common/date_time_picker.dart b/mobile/lib/widgets/common/date_time_picker.dart index 3aedd55de5..86154c308e 100644 --- a/mobile/lib/widgets/common/date_time_picker.dart +++ b/mobile/lib/widgets/common/date_time_picker.dart @@ -1,10 +1,11 @@ import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/widgets/common/dropdown_search_menu.dart'; +import 'package:intl/intl.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart'; @@ -116,26 +117,26 @@ class _DateTimePicker extends HookWidget { TextButton( onPressed: () => context.pop(), child: Text( - "cancel", + context.t.cancel, style: context.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: context.colorScheme.error, ), - ).tr(), + ), ), TextButton( onPressed: popWithDateTime, child: Text( - "action_common_update", + context.t.action_common_update, style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor), - ).tr(), + ), ), ], content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("date_and_time", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)).tr(), + Text(context.t.date_and_time, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(height: 32), ListTile( tileColor: context.colorScheme.surfaceContainerHighest, @@ -151,8 +152,8 @@ class _DateTimePicker extends HookWidget { const SizedBox(height: 24), DropdownSearchMenu( trailingIcon: Icon(Icons.arrow_drop_down, color: context.primaryColor), - hintText: "timezone".tr(), - label: const Text('timezone').tr(), + hintText: context.t.timezone, + label: Text(context.t.timezone), textStyle: context.textTheme.bodyMedium, onSelected: (value) => tzOffset.value = value, initialSelection: tzOffset.value, diff --git a/mobile/lib/widgets/common/dropdown_search_menu.dart b/mobile/lib/widgets/common/dropdown_search_menu.dart index 54568c392a..ba40bb98d5 100644 --- a/mobile/lib/widgets/common/dropdown_search_menu.dart +++ b/mobile/lib/widgets/common/dropdown_search_menu.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class DropdownSearchMenu extends HookWidget { const DropdownSearchMenu({ @@ -81,7 +81,7 @@ class DropdownSearchMenu extends HookWidget { autofocus: true, focusNode: focusNode, controller: textEditingController, - decoration: inputDecoration.copyWith(hintText: "search_timezone".tr()), + decoration: inputDecoration.copyWith(hintText: context.t.search_timezone), maxLines: 1, style: context.textTheme.bodyMedium, expands: false, diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index c5bce92cff..b966601c31 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; @@ -73,7 +73,7 @@ class ImmichSliverAppBar extends ConsumerWidget { onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()), icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), ), - if (actions != null) ...actions!, + ...?actions, if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(), const _ProfileIndicator(), const SizedBox(width: 8), @@ -113,14 +113,14 @@ class _ProfileIndicator extends ConsumerWidget { final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile; void toggleReadonlyMode() { - final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + final isReadonlyModeEnabled = ref.read(readonlyModeProvider); ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); context.scaffoldMessenger.showSnackBar( SnackBar( duration: const Duration(seconds: 2), content: Text( - (isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(), + isReadonlyModeEnabled ? context.t.readonly_mode_disabled : context.t.readonly_mode_enabled, style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), ), ), @@ -143,7 +143,7 @@ class _ProfileIndicator extends ConsumerWidget { ? context.colorScheme.error : context.primaryColor, size: widgetSize / 2 - 3, - semanticLabel: 'new_version_available'.tr(), + semanticLabel: context.t.new_version_available, ), ), backgroundColor: Colors.transparent, @@ -153,7 +153,7 @@ class _ProfileIndicator extends ConsumerWidget { child: user == null ? const Icon(Icons.face_outlined, size: widgetSize) : Semantics( - label: "logged_in_as".tr(namedArgs: {"user": user.name}), + label: context.t.logged_in_as(user: user.name), child: AbsorbPointer( child: Builder( builder: (context) => UserCircleAvatar( @@ -177,7 +177,15 @@ class _BackupIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final indicatorIcon = _getBackupBadgeIcon(context, ref); + final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled)); + final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none)); + final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); + final indicatorIcon = _getBackupBadgeIcon( + context, + backupEnabled: backupEnabled, + hasError: hasError, + isUploading: isUploading, + ); return IconButton( onPressed: () => context.pushRoute(const DriftBackupRoute()), @@ -192,16 +200,23 @@ class _BackupIndicator extends ConsumerWidget { ); } - Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) { - final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled)); - final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none)); + Widget? _getBackupBadgeIcon( + BuildContext context, { + required bool backupEnabled, + required bool hasError, + required bool isUploading, + }) { final isDarkTheme = context.isDarkTheme; final iconColor = isDarkTheme ? Colors.white : Colors.black; - final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); if (!backupEnabled) { return _BadgeLabel( - Icon(Icons.cloud_off_rounded, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()), + Icon( + Icons.cloud_off_rounded, + size: 9, + color: iconColor, + semanticLabel: context.t.backup_controller_page_backup, + ), ); } @@ -211,7 +226,7 @@ class _BackupIndicator extends ConsumerWidget { Icons.warning_rounded, size: 12, color: context.colorScheme.error, - semanticLabel: 'backup_controller_page_backup'.tr(), + semanticLabel: context.t.backup_controller_page_backup, ), backgroundColor: context.colorScheme.errorContainer, ); @@ -229,7 +244,7 @@ class _BackupIndicator extends ConsumerWidget { strokeWidth: 2, strokeCap: StrokeCap.round, valueColor: AlwaysStoppedAnimation(iconColor), - semanticsLabel: 'backup_controller_page_backup'.tr(), + semanticsLabel: context.t.backup_controller_page_backup, ), ), ), @@ -237,7 +252,7 @@ class _BackupIndicator extends ConsumerWidget { } return _BadgeLabel( - Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()), + Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: context.t.backup_controller_page_backup), ); } } diff --git a/mobile/lib/widgets/common/local_album_sliver_app_bar.dart b/mobile/lib/widgets/common/local_album_sliver_app_bar.dart index 85d8f4bb01..1de1d1a262 100644 --- a/mobile/lib/widgets/common/local_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/local_album_sliver_app_bar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class LocalAlbumsSliverAppBar extends StatelessWidget { const LocalAlbumsSliverAppBar({super.key}); @@ -15,7 +15,7 @@ class LocalAlbumsSliverAppBar extends StatelessWidget { shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), automaticallyImplyLeading: true, centerTitle: true, - title: Text("on_this_device".t(context: context)), + title: Text(context.t.on_this_device), ); } } diff --git a/mobile/lib/widgets/common/location_picker.dart b/mobile/lib/widgets/common/location_picker.dart index c7eb827781..cf3240977b 100644 --- a/mobile/lib/widgets/common/location_picker.dart +++ b/mobile/lib/widgets/common/location_picker.dart @@ -1,10 +1,10 @@ import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -73,11 +73,11 @@ class _LocationPicker extends HookWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("edit_location_dialog_title", style: context.textTheme.titleMedium).tr(), + Text(context.t.edit_location_dialog_title, style: context.textTheme.titleMedium), Align( alignment: Alignment.center, child: TextButton.icon( - icon: const Text("location_picker_choose_on_map").tr(), + icon: Text(context.t.location_picker_choose_on_map), label: const Icon(Icons.map_outlined, size: 16), onPressed: onMapTap, ), @@ -85,9 +85,9 @@ class _LocationPicker extends HookWidget { const SizedBox(height: 12), _ManualPickerInput( controller: latitudeController, - decorationText: "latitude", - hintText: "location_picker_latitude_hint", - errorText: "location_picker_latitude_error", + decorationText: context.t.latitude, + hintText: context.t.location_picker_latitude_hint, + errorText: context.t.location_picker_latitude_error, focusNode: latitiudeFocusNode, validator: _validateLat, onUpdated: onLatitudeUpdated, @@ -95,9 +95,9 @@ class _LocationPicker extends HookWidget { const SizedBox(height: 24), _ManualPickerInput( controller: longitudeController, - decorationText: "longitude", - hintText: "location_picker_longitude_hint", - errorText: "location_picker_longitude_error", + decorationText: context.t.longitude, + hintText: context.t.location_picker_longitude_hint, + errorText: context.t.location_picker_longitude_error, focusNode: longitudeFocusNode, validator: _validateLong, onUpdated: onLongitudeEditingCompleted, @@ -109,19 +109,19 @@ class _LocationPicker extends HookWidget { TextButton( onPressed: () => ContextHelper(context).pop(), child: Text( - "cancel", + context.t.cancel, style: context.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: context.colorScheme.error, ), - ).tr(), + ), ), TextButton( onPressed: () => context.maybePop(latlng), child: Text( - "action_common_update", + context.t.action_common_update, style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor), - ).tr(), + ), ), ], ); @@ -163,13 +163,13 @@ class _ManualPickerInput extends HookWidget { textInputAction: TextInputAction.done, autofocus: false, decoration: InputDecoration( - labelText: decorationText.tr(), + labelText: decorationText, labelStyle: TextStyle(fontWeight: FontWeight.bold, color: context.primaryColor), floatingLabelBehavior: FloatingLabelBehavior.auto, border: const OutlineInputBorder(), - hintText: hintText.tr(), + hintText: hintText, hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), - errorText: isValid.value ? null : errorText.tr(), + errorText: isValid.value ? null : errorText, ), onEditingComplete: onEditingComplete, keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index 0ceb98aa76..ac53bca72f 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -237,7 +237,7 @@ class _ItemCountTextState extends ConsumerState<_ItemCountText> { final assetCount = ref.watch(timelineServiceProvider.select((s) => s.totalAssets)); return Text( - 'items_count'.t(context: context, args: {"count": assetCount}), + context.t.items_count(count: assetCount), style: context.textTheme.labelLarge?.copyWith( // letterSpacing: 0.2, fontWeight: FontWeight.bold, diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 0e6829243c..56eb3c6094 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -28,7 +28,7 @@ class PersonSliverAppBar extends ConsumerStatefulWidget { required this.onBirthdayTap, }); - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; final VoidCallback onShowOptions; @@ -137,7 +137,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState { class _ExpandedBackground extends ConsumerStatefulWidget { final double scrollProgress; - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; @@ -261,7 +261,7 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S ), ) : Text( - 'add_a_name'.tr(), + context.t.add_a_name, style: context.textTheme.titleLarge?.copyWith( color: Colors.grey[400], fontSize: 36, @@ -293,7 +293,7 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S ) else Text( - 'add_birthday'.tr(), + context.t.add_birthday, style: context.textTheme.labelLarge?.copyWith( color: Colors.grey[400], height: 1.2, @@ -344,7 +344,7 @@ class _ItemCountTextState extends ConsumerState<_ItemCountText> { final assetCount = ref.watch(timelineServiceProvider.select((s) => s.totalAssets)); return Text( - 'items_count'.t(context: context, args: {"count": assetCount}), + context.t.items_count(count: assetCount), style: context.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.bold, color: Colors.white, diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 09a0367039..4b9532eb88 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; @@ -318,7 +318,7 @@ class _ItemCountTextState extends ConsumerState<_ItemCountText> { final assetCount = ref.watch(timelineServiceProvider.select((s) => s.totalAssets)); return Text( - 'items_count'.t(context: context, args: {"count": assetCount}), + context.t.items_count(count: assetCount), style: context.textTheme.labelLarge?.copyWith( color: Colors.white, shadows: [const Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black87)], diff --git a/mobile/lib/widgets/common/scaffold_error_body.dart b/mobile/lib/widgets/common/scaffold_error_body.dart index 2e2d8fb506..5f3fb1cb0d 100644 --- a/mobile/lib/widgets/common/scaffold_error_body.dart +++ b/mobile/lib/widgets/common/scaffold_error_body.dart @@ -1,6 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; // Error widget to be used in Scaffold when an AsyncError is received class ScaffoldErrorBody extends StatelessWidget { @@ -15,7 +15,11 @@ class ScaffoldErrorBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text("scaffold_body_error_occurred", style: context.textTheme.displayMedium, textAlign: TextAlign.center).tr(), + Text( + context.t.scaffold_body_error_occurred, + style: context.textTheme.displayMedium, + textAlign: TextAlign.center, + ), if (withIcon) Center( child: Padding( diff --git a/mobile/lib/widgets/common/selection_sliver_app_bar.dart b/mobile/lib/widgets/common/selection_sliver_app_bar.dart index d60124041c..f6fb2fc88f 100644 --- a/mobile/lib/widgets/common/selection_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/selection_sliver_app_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; class SelectionSliverAppBar extends ConsumerStatefulWidget { @@ -43,12 +43,12 @@ class _SelectionSliverAppBarState extends ConsumerState { }, ), centerTitle: true, - title: Text("Select {count}".t(context: context, args: {'count': filteredAssets.length.toString()})), + title: Text(context.t.selected_count(count: filteredAssets.length)), actions: [ TextButton( onPressed: () => onDone(filteredAssets), child: Text( - 'done'.t(context: context), + context.t.done, style: context.textTheme.titleSmall?.copyWith(color: context.colorScheme.primary), ), ), diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart index a9a68fe044..2b4f07536f 100644 --- a/mobile/lib/widgets/common/tag_picker.dart +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -1,10 +1,10 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; @@ -17,13 +17,13 @@ Future<(Set, Set)?> showTagPickerModal({required BuildContext co ); } -class _TagPickerModal extends HookConsumerWidget { +class _TagPickerModal extends HookWidget { final Set? initialSelection; const _TagPickerModal({this.initialSelection}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final selectedTagIds = useState>(initialSelection ?? {}); final newTagValues = useState>({}); @@ -41,19 +41,19 @@ class _TagPickerModal extends HookConsumerWidget { TextButton( onPressed: () => context.pop(), child: Text( - "cancel", + context.t.cancel, style: context.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: context.colorScheme.error, ), - ).tr(), + ), ), TextButton( onPressed: () => context.pop((selectedTagIds.value, newTagValues.value)), child: Text( - "action_common_update", + context.t.action_common_update, style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor), - ).tr(), + ), ), ], content: SizedBox( @@ -98,7 +98,7 @@ class TagPicker extends HookConsumerWidget { onChanged: (value) => searchQuery.value = value, onTapOutside: (_) => formFocus.unfocus(), filled: true, - hintText: 'filter_tags'.tr(), + hintText: context.t.filter_tags, ), ), Padding( diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index c6e4f4719e..11257da77b 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -// ignore: must_be_immutable -class UserCircleAvatar extends ConsumerWidget { +class UserCircleAvatar extends StatelessWidget { final UserDto user; - double size; - bool hasBorder; - double opacity; + final double size; + final bool hasBorder; + final double opacity; - UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user}); + const UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity); final profileImageUrl = '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart index dd307f4ede..65a4c6f88e 100644 --- a/mobile/lib/widgets/forms/change_password_form.dart +++ b/mobile/lib/widgets/forms/change_password_form.dart @@ -1,10 +1,10 @@ import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -29,13 +29,13 @@ class ChangePasswordForm extends HookConsumerWidget { alignment: WrapAlignment.start, children: [ Text( - 'change_password'.tr(), + context.t.change_password, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: context.primaryColor), ), Padding( padding: const EdgeInsets.symmetric(vertical: 24.0), child: Text( - 'change_password_form_description'.tr(namedArgs: {'name': authState.name}), + context.t.change_password_form_description(name: authState.name), style: TextStyle(fontSize: 14, color: context.colorScheme.onSurface, fontWeight: FontWeight.w600), ), ), @@ -62,23 +62,27 @@ class ChangePasswordForm extends HookConsumerWidget { if (!isSuccess && context.mounted) { ImmichToast.show( context: context, - msg: "login_password_changed_error".tr(), + msg: context.t.login_password_changed_error, toastType: ToastType.error, gravity: ToastGravity.TOP, ); return; } - await ref.read(authProvider.notifier).logout(); - ref.read(websocketProvider.notifier).disconnect(); if (!context.mounted) { return; } + await ref.read(authProvider.notifier).logout(); + if (!context.mounted) { + return; + } + + ref.read(websocketProvider.notifier).disconnect(); AutoRouter.of(context).back(); ImmichToast.show( context: context, - msg: "login_password_changed_success".tr(), + msg: context.t.login_password_changed_success, toastType: ToastType.success, gravity: ToastGravity.TOP, ); @@ -88,7 +92,7 @@ class ChangePasswordForm extends HookConsumerWidget { TextButton.icon( icon: const Icon(Icons.arrow_back), onPressed: () => AutoRouter.of(context).back(), - label: const Text('back').tr(), + label: Text(context.t.back), ), ], ), @@ -112,9 +116,9 @@ class PasswordInput extends StatelessWidget { obscureText: true, controller: controller, decoration: InputDecoration( - labelText: 'change_password_form_new_password'.tr(), + labelText: context.t.change_password_form_new_password, border: const OutlineInputBorder(), - hintText: 'change_password_form_new_password'.tr(), + hintText: context.t.change_password_form_new_password, ), ); } @@ -128,7 +132,7 @@ class ConfirmPasswordInput extends StatelessWidget { String? _validateInput(String? email) { if (confirmController.value != originalController.value) { - return 'change_password_form_password_mismatch'.tr(); + return StaticTranslations.instance.change_password_form_password_mismatch; } return null; } @@ -139,8 +143,8 @@ class ConfirmPasswordInput extends StatelessWidget { obscureText: true, controller: confirmController, decoration: InputDecoration( - labelText: 'change_password_form_confirm_password'.tr(), - hintText: 'change_password_form_reenter_new_password'.tr(), + labelText: context.t.change_password_form_confirm_password, + hintText: context.t.change_password_form_reenter_new_password, border: const OutlineInputBorder(), ), validator: _validateInput, @@ -149,20 +153,20 @@ class ConfirmPasswordInput extends StatelessWidget { } } -class ChangePasswordButton extends ConsumerWidget { +class ChangePasswordButton extends StatelessWidget { final TextEditingController passwordController; final VoidCallback onPressed; const ChangePasswordButton({super.key, required this.passwordController, required this.onPressed}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return ElevatedButton( style: ElevatedButton.styleFrom( visualDensity: VisualDensity.standard, padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25), ), onPressed: onPressed, - child: Text('change_password'.tr(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + child: Text(context.t.change_password, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), ); } } diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 18963f0cfd..aa9750a2a5 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -5,7 +5,6 @@ import 'dart:math'; import 'package:auto_route/auto_route.dart'; import 'package:crypto/crypto.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; @@ -14,7 +13,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; @@ -43,20 +42,21 @@ class LoginForm extends HookConsumerWidget { final log = Logger('LoginForm'); - String? _validateUrl(String? url) => normalizeAndValidateServerUrl(url) ? null : 'login_form_err_invalid_url'.tr(); + String? _validateUrl(String? url) => + normalizeAndValidateServerUrl(url) ? null : StaticTranslations.instance.login_form_err_invalid_url; String? _validateEmail(String? email) { if (email == null || email == '') { return null; } if (email.endsWith(' ')) { - return 'login_form_err_trailing_whitespace'.tr(); + return StaticTranslations.instance.login_form_err_trailing_whitespace; } if (email.startsWith(' ')) { - return 'login_form_err_leading_whitespace'.tr(); + return StaticTranslations.instance.login_form_err_leading_whitespace; } if (email.contains(' ') || !email.contains('@')) { - return 'login_form_err_invalid_email'.tr(); + return StaticTranslations.instance.login_form_err_invalid_email; } return null; } @@ -96,14 +96,20 @@ class LoginForm extends HookConsumerWidget { // Guard empty URL if (serverUrl.isEmpty) { - ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error); + ImmichToast.show(context: context, msg: context.t.login_form_server_empty, toastType: ToastType.error); } try { final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl); + if (!context.mounted) { + return; + } // Fetch and load server config and features await ref.read(serverInfoProvider.notifier).getServerInfo(); + if (!context.mounted) { + return; + } final serverInfo = ref.read(serverInfoProvider); final features = serverInfo.serverFeatures; @@ -121,7 +127,7 @@ class LoginForm extends HookConsumerWidget { ImmichToast.show( context: context, - msg: e.message ?? 'login_form_api_exception'.tr(), + msg: e.message ?? context.t.login_form_api_exception, toastType: ToastType.error, gravity: ToastGravity.TOP, ); @@ -134,7 +140,7 @@ class LoginForm extends HookConsumerWidget { ImmichToast.show( context: context, - msg: 'login_form_handshake_exception'.tr(), + msg: context.t.login_form_handshake_exception, toastType: ToastType.error, gravity: ToastGravity.TOP, ); @@ -147,7 +153,7 @@ class LoginForm extends HookConsumerWidget { ImmichToast.show( context: context, - msg: 'login_form_server_error'.tr(), + msg: context.t.login_form_server_error, toastType: ToastType.error, gravity: ToastGravity.TOP, ); @@ -204,15 +210,15 @@ class LoginForm extends HookConsumerWidget { shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), elevation: 5, title: Text( - 'manage_media_access_title', + context.t.manage_media_access_title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor), - ).tr(), + ), content: SingleChildScrollView( child: ListBody( children: [ - const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(), + Text(context.t.manage_media_access_subtitle, style: const TextStyle(fontSize: 14)), const SizedBox(height: 4), - const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(), + Text(context.t.manage_media_access_rationale, style: const TextStyle(fontSize: 12)), ], ), ), @@ -220,7 +226,7 @@ class LoginForm extends HookConsumerWidget { TextButton( onPressed: () => Navigator.of(context).pop(), child: Text( - 'cancel'.tr(), + context.t.cancel, style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor), ), ), @@ -230,7 +236,7 @@ class LoginForm extends HookConsumerWidget { Navigator.of(context).pop(); }, child: Text( - 'manage_media_access_settings'.tr(), + context.t.manage_media_access_settings, style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor), ), ), @@ -264,6 +270,10 @@ class LoginForm extends HookConsumerWidget { await getManageMediaPermission(); } unawaited(handleSyncFlow()); + if (!context.mounted) { + return; + } + ref.read(websocketProvider.notifier).connect(); unawaited(ref.read(featureMessageServiceProvider).markSeen()); if (!context.mounted) { @@ -280,7 +290,7 @@ class LoginForm extends HookConsumerWidget { ImmichToast.show( context: context, - msg: "login_form_failed_login".tr(), + msg: context.t.login_form_failed_login, toastType: ToastType.error, gravity: ToastGravity.TOP, ); @@ -312,7 +322,7 @@ class LoginForm extends HookConsumerWidget { } Future oAuthLogin() async { - final oAuthService = ref.watch(oAuthServiceProvider); + final oAuthService = ref.read(oAuthServiceProvider); String? oAuthServerUrl; final state = generateRandomString(32); @@ -338,7 +348,7 @@ class LoginForm extends HookConsumerWidget { ImmichToast.show( context: context, - msg: "login_form_failed_get_oauth_server_config".tr(), + msg: context.t.login_form_failed_get_oauth_server_config, toastType: ToastType.error, gravity: ToastGravity.TOP, ); @@ -349,27 +359,27 @@ class LoginForm extends HookConsumerWidget { try { final loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl, state, codeVerifier); - if (loginResponseDto == null) { + if (loginResponseDto == null || !context.mounted) { return; } log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}"); final isSuccess = await ref - .watch(authProvider.notifier) + .read(authProvider.notifier) .saveAuthInfo(accessToken: loginResponseDto.accessToken); - if (isSuccess) { + if (isSuccess && context.mounted) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); if (isSyncRemoteDeletionsMode()) { await getManageMediaPermission(); } unawaited(handleSyncFlow()); - unawaited(ref.read(featureMessageServiceProvider).markSeen()); if (!context.mounted) { return; } + unawaited(ref.read(featureMessageServiceProvider).markSeen()); unawaited(context.router.replaceAll([const TabShellRoute()])); return; } @@ -394,7 +404,7 @@ class LoginForm extends HookConsumerWidget { ImmichToast.show( context: context, - msg: "login_form_failed_get_oauth_server_disable".tr(), + msg: context.t.login_form_failed_get_oauth_server_disable, toastType: ToastType.info, gravity: ToastGravity.TOP, ); @@ -441,19 +451,19 @@ class LoginForm extends HookConsumerWidget { children: [ ImmichForm( onSubmit: getServerAuthSettings, - submitText: 'next'.t(context: context), + submitText: context.t.next, submitIcon: Icons.arrow_forward_rounded, builder: (_, form) => ImmichURLInput( controller: serverEndpointController, - label: 'login_form_endpoint_url'.t(context: context), - hintText: 'login_form_endpoint_hint'.t(context: context), + label: context.t.login_form_endpoint_url, + hintText: context.t.login_form_endpoint_hint, validator: _validateUrl, keyboardAction: .next, onSubmit: (_) => form.submit(), ), ), ImmichTextButton( - labelText: 'settings'.t(context: context), + labelText: context.t.settings, icon: Icons.settings, variant: ImmichVariant.ghost, onPressed: () => context.pushRoute(const SettingsRoute()), @@ -478,26 +488,24 @@ class LoginForm extends HookConsumerWidget { if (isPasswordLoginEnable.value) ImmichForm( onSubmit: login, - submitText: 'login'.t(context: context), + submitText: context.t.login, submitIcon: Icons.login_rounded, builder: (context, form) => Column( spacing: ImmichSpacing.md, children: [ - ImmichTextInput( + ImmichEmailInput( controller: emailController, - label: 'email'.t(context: context), - hintText: 'login_form_email_hint'.t(context: context), + label: context.t.email, + hintText: context.t.login_form_email_hint, validator: _validateEmail, keyboardAction: TextInputAction.next, - keyboardType: TextInputType.emailAddress, - autofillHints: const [AutofillHints.email], onSubmit: (_) => passwordFocusNode.requestFocus(), ), ImmichPasswordInput( controller: passwordController, focusNode: passwordFocusNode, - label: 'password'.t(context: context), - hintText: 'login_form_password_hint'.t(context: context), + label: context.t.password, + hintText: context.t.login_form_password_hint, keyboardAction: TextInputAction.go, onSubmit: (_) => form.submit(), ), @@ -516,10 +524,9 @@ class LoginForm extends HookConsumerWidget { ) : const SizedBox.shrink(), ), - if (!isOauthEnable.value && !isPasswordLoginEnable.value) - Center(child: const Text('login_disabled').tr()), + if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: Text(context.t.login_disabled)), ImmichTextButton( - labelText: 'back'.t(context: context), + labelText: context.t.back, icon: Icons.arrow_back, variant: ImmichVariant.ghost, onPressed: () => serverEndpoint.value = null, diff --git a/mobile/lib/widgets/forms/pin_registration_form.dart b/mobile/lib/widgets/forms/pin_registration_form.dart index ebd299bcce..bc14388021 100644 --- a/mobile/lib/widgets/forms/pin_registration_form.dart +++ b/mobile/lib/widgets/forms/pin_registration_form.dart @@ -1,8 +1,8 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/forms/pin_input.dart'; @@ -58,7 +58,7 @@ class PinRegistrationForm extends HookConsumerWidget { SizedBox( width: context.width * 0.7, child: Text( - 'setup_pin_code'.tr(), + context.t.setup_pin_code, style: context.textTheme.labelLarge!.copyWith(fontSize: 24), textAlign: TextAlign.center, ), @@ -66,7 +66,7 @@ class PinRegistrationForm extends HookConsumerWidget { SizedBox( width: context.width * 0.8, child: Text( - 'new_pin_code_subtitle'.tr(), + context.t.new_pin_code_subtitle, style: context.textTheme.bodyLarge!.copyWith(fontSize: 16), textAlign: TextAlign.center, ), @@ -74,7 +74,7 @@ class PinRegistrationForm extends HookConsumerWidget { const SizedBox(height: 32), PinInput( controller: newPinCodeController, - label: 'new_pin_code'.tr(), + label: context.t.new_pin_code, length: 6, autoFocus: true, hasError: hasError.value, @@ -87,7 +87,7 @@ class PinRegistrationForm extends HookConsumerWidget { const SizedBox(height: 32), PinInput( controller: confirmPinCodeController, - label: 'confirm_new_pin_code'.tr(), + label: context.t.confirm_new_pin_code, length: 6, hasError: hasError.value, onChanged: (input) { @@ -102,7 +102,7 @@ class PinRegistrationForm extends HookConsumerWidget { child: Row( children: [ Expanded( - child: ElevatedButton(onPressed: createNewPinCode, child: Text('create'.tr())), + child: ElevatedButton(onPressed: createNewPinCode, child: Text(context.t.create)), ), ], ), diff --git a/mobile/lib/widgets/forms/pin_verification_form.dart b/mobile/lib/widgets/forms/pin_verification_form.dart index de2acaa1f7..6f1ba25fd0 100644 --- a/mobile/lib/widgets/forms/pin_verification_form.dart +++ b/mobile/lib/widgets/forms/pin_verification_form.dart @@ -1,8 +1,8 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/forms/pin_input.dart'; @@ -60,7 +60,7 @@ class PinVerificationForm extends HookConsumerWidget { SizedBox( width: context.width * 0.7, child: Text( - description ?? 'enter_your_pin_code_subtitle'.tr(), + description ?? context.t.enter_your_pin_code_subtitle, style: context.textTheme.labelLarge!.copyWith(fontSize: 18), textAlign: TextAlign.center, ), diff --git a/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart b/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart index 2a4dacaff7..eeebc28dda 100644 --- a/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart +++ b/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart @@ -1,7 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class MapTimeDropDown extends StatelessWidget { final int relativeTime; @@ -20,7 +19,7 @@ class MapTimeDropDown extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "date_range".t(context: context), + context.t.date_range, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), ), Flexible( @@ -30,24 +29,21 @@ class MapTimeDropDown extends StatelessWidget { initialSelection: relativeTime, onSelected: (value) => onTimeChange(value!), dropdownMenuEntries: [ - DropdownMenuEntry(value: 0, label: "all".t(context: context)), - DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".t(context: context)), - DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})), - DropdownMenuEntry( - value: 30, - label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"}), - ), + DropdownMenuEntry(value: 0, label: context.t.all), + DropdownMenuEntry(value: 1, label: context.t.map_settings_date_range_option_day), + DropdownMenuEntry(value: 7, label: context.t.map_settings_date_range_option_days(days: 7)), + DropdownMenuEntry(value: 30, label: context.t.map_settings_date_range_option_days(days: 30)), DropdownMenuEntry( value: now .difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second)) .inDays, - label: "map_settings_date_range_option_year".t(context: context), + label: context.t.map_settings_date_range_option_year, ), DropdownMenuEntry( value: now .difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second)) .inDays, - label: "map_settings_date_range_option_years".t(args: {'years': "3"}), + label: context.t.map_settings_date_range_option_years(years: 3), ), ], ), diff --git a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart index e66f08c221..6b53a273a0 100644 --- a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart +++ b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -18,7 +18,7 @@ class MapThemePicker extends StatelessWidget { padding: const EdgeInsets.only(bottom: 20), child: Center( child: Text( - "map_settings_theme_settings".t(context: context), + context.t.map_settings_theme_settings, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), ), ), @@ -79,7 +79,7 @@ class _BorderedMapThumbnail extends StatelessWidget { child: MapThumbnail( zoom: 2, centre: const LatLng(47, 5), - onTap: (_, __) => onThemeChange(mode), + onTap: (_, _) => onThemeChange(mode), themeMode: mode, showAttribution: false, ), diff --git a/mobile/lib/widgets/map/map_theme_override.dart b/mobile/lib/widgets/map/map_theme_override.dart index dda96efeaf..93cbd8846f 100644 --- a/mobile/lib/widgets/map/map_theme_override.dart +++ b/mobile/lib/widgets/map/map_theme_override.dart @@ -69,7 +69,7 @@ class _MapThemeOverrideState extends ConsumerState with Widget final appTheme = ref.watch(immichThemeProvider); final locale = ref.watch(localeProvider); - useValueChanged(_theme, (_, __) { + useValueChanged(_theme, (_, _) { if (_theme == ThemeMode.system) { WidgetsBinding.instance.addObserver(this); } else { diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index eb4653e17e..84c2ac0100 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; @@ -15,7 +14,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// User can provide either a [assetMarkerRemoteId] to display the asset's thumbnail or set /// [showMarkerPin] to true which would display a marker pin instead. If both are provided, /// [assetMarkerRemoteId] will take precedence -class MapThumbnail extends HookConsumerWidget { +class MapThumbnail extends HookWidget { final Function(Point, LatLng)? onTap; final LatLng centre; final String? assetMarkerRemoteId; @@ -44,7 +43,7 @@ class MapThumbnail extends HookConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final controller = useRef(null); final styleLoaded = useState(false); diff --git a/mobile/lib/widgets/memories/memory_epilogue.dart b/mobile/lib/widgets/memories/memory_epilogue.dart index b866ed049a..0335b586ac 100644 --- a/mobile/lib/widgets/memories/memory_epilogue.dart +++ b/mobile/lib/widgets/memories/memory_epilogue.dart @@ -1,6 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class MemoryEpilogue extends StatefulWidget { final Function()? onStartOver; @@ -45,23 +45,23 @@ class _MemoryEpilogueState extends State with TickerProviderStat ), const SizedBox(height: 16.0), Text( - "memories_all_caught_up", + context.t.memories_all_caught_up, style: context.textTheme.headlineMedium?.copyWith(color: Colors.white), - ).tr(), + ), const SizedBox(height: 16.0), Text( - "memories_check_back_tomorrow", + context.t.memories_check_back_tomorrow, style: context.textTheme.bodyMedium?.copyWith(color: Colors.white), - ).tr(), + ), const SizedBox(height: 16.0), TextButton( onPressed: widget.onStartOver, child: Text( - "memories_start_over", + context.t.memories_start_over, style: context.textTheme.displayMedium?.copyWith( color: context.isDarkTheme ? context.colorScheme.primary : context.colorScheme.inversePrimary, ), - ).tr(), + ), ), ], ), @@ -85,9 +85,9 @@ class _MemoryEpilogueState extends State with TickerProviderStat ), ), Text( - "memories_swipe_to_close", + context.t.memories_swipe_to_close, style: context.textTheme.bodyMedium?.copyWith(color: Colors.white), - ).tr(), + ), ], ), ), diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index aa33d18403..3c41b2ca19 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -128,8 +128,8 @@ class PhotoViewGallery extends StatefulWidget { /// The builder must return a [PhotoViewGalleryPageOptions]. const PhotoViewGallery.builder({ super.key, - required this.itemCount, - required this.builder, + required int this.itemCount, + required PhotoViewGalleryBuilder this.builder, this.loadingBuilder, this.backgroundDecoration, this.wantKeepAlive = false, @@ -145,9 +145,7 @@ class PhotoViewGallery extends StatefulWidget { this.customSize, this.allowImplicitScrolling = false, this.enablePanAlways = false, - }) : pageOptions = null, - assert(itemCount != null), - assert(builder != null); + }) : pageOptions = null; /// A list of options to describe the items in the gallery final List? pageOptions; @@ -352,9 +350,9 @@ class _PhotoViewGalleryState extends State { /// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant /// class PhotoViewGalleryPageOptions { - PhotoViewGalleryPageOptions({ + const PhotoViewGalleryPageOptions({ this.key, - required this.imageProvider, + required ImageProvider this.imageProvider, this.heroAttributes, this.semanticLabel, this.minScale, @@ -379,8 +377,7 @@ class PhotoViewGalleryPageOptions { this.disableGestures, this.errorBuilder, }) : child = null, - childSize = null, - assert(imageProvider != null); + childSize = null; const PhotoViewGalleryPageOptions.customChild({ this.key, diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index c230a65690..a8249b48f9 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -203,7 +203,7 @@ class PhotoViewCoreState extends State } else if (scaleState == PhotoViewScaleState.zoomedIn) { animateRotation(controller.rotation, 0); if (_shouldAllowPanRotate()) { - animatePosition(controller.position, Offset.zero); + animatePosition(controller.position, clampPosition()); } } diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 7b55e0e37e..8f408e043e 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -121,7 +121,6 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer { super.debugOwner, this.validateAxis, this.touchSlopFactor = 1, - PointerDeviceKind? kind, this.disableScaleGestures = false, }) : super(supportedDevices: null); final HitCornersDetector? hitDetector; diff --git a/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart b/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart index 3ca31cb8f9..27aa256b14 100644 --- a/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart +++ b/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart @@ -28,12 +28,12 @@ class IgnorableChangeNotifier extends ChangeNotifier { return super.hasListeners || (_ignorableListeners?.isNotEmpty ?? false); } - void addIgnorableListener(listener) { + void addIgnorableListener(VoidCallback listener) { assert(_debugAssertNotDisposed()); _ignorableListeners!.add(listener); } - void removeIgnorableListener(listener) { + void removeIgnorableListener(VoidCallback listener) { assert(_debugAssertNotDisposed()); _ignorableListeners!.remove(listener); } diff --git a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart index d120955250..c1ee1ab769 100644 --- a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart +++ b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart @@ -32,12 +32,10 @@ class ScaleBoundaries { double get minScale { assert(_minScale is double || _minScale is PhotoViewComputedScale); if (_minScale == PhotoViewComputedScale.contained) { - return _scaleForContained(outerSize, childSize) * - (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as + return _scaleForContained(outerSize, childSize) * (_minScale as PhotoViewComputedScale).multiplier; } if (_minScale == PhotoViewComputedScale.covered) { - return _scaleForCovering(outerSize, childSize) * - (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as + return _scaleForCovering(outerSize, childSize) * (_minScale as PhotoViewComputedScale).multiplier; } assert(_minScale >= 0.0); return _minScale; @@ -46,16 +44,16 @@ class ScaleBoundaries { double get maxScale { assert(_maxScale is double || _maxScale is PhotoViewComputedScale); if (_maxScale == PhotoViewComputedScale.contained) { - return (_scaleForContained(outerSize, childSize) * - (_maxScale as PhotoViewComputedScale) // ignore: avoid_as - .multiplier) - .clamp(minScale, double.infinity); + return (_scaleForContained(outerSize, childSize) * (_maxScale as PhotoViewComputedScale).multiplier).clamp( + minScale, + double.infinity, + ); } if (_maxScale == PhotoViewComputedScale.covered) { - return (_scaleForCovering(outerSize, childSize) * - (_maxScale as PhotoViewComputedScale) // ignore: avoid_as - .multiplier) - .clamp(minScale, double.infinity); + return (_scaleForCovering(outerSize, childSize) * (_maxScale as PhotoViewComputedScale).multiplier).clamp( + minScale, + double.infinity, + ); } return _maxScale.clamp(minScale, double.infinity); } @@ -63,14 +61,10 @@ class ScaleBoundaries { double get initialScale { assert(_initialScale is double || _initialScale is PhotoViewComputedScale); if (_initialScale == PhotoViewComputedScale.contained) { - return _scaleForContained(outerSize, childSize) * - (_initialScale as PhotoViewComputedScale) // ignore: avoid_as - .multiplier; + return _scaleForContained(outerSize, childSize) * (_initialScale as PhotoViewComputedScale).multiplier; } if (_initialScale == PhotoViewComputedScale.covered) { - return _scaleForCovering(outerSize, childSize) * - (_initialScale as PhotoViewComputedScale) // ignore: avoid_as - .multiplier; + return _scaleForCovering(outerSize, childSize) * (_initialScale as PhotoViewComputedScale).multiplier; } return _initialScale.clamp(minScale, maxScale); } diff --git a/mobile/lib/widgets/search/search_filter/camera_picker.dart b/mobile/lib/widgets/search/search_filter/camera_picker.dart index 6a025bdb69..aad5fda18f 100644 --- a/mobile/lib/widgets/search/search_filter/camera_picker.dart +++ b/mobile/lib/widgets/search/search_filter/camera_picker.dart @@ -1,8 +1,8 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/search_filter.provider.dart'; import 'package:immich_mobile/widgets/search/search_filter/common/dropdown.dart'; @@ -21,7 +21,9 @@ class CameraPicker extends HookConsumerWidget { final selectedMake = useState(filter?.make); final selectedModel = useState(filter?.model); - final make = ref.watch(getSearchSuggestionsProvider(SearchSuggestionArgs(type: SearchSuggestionType.cameraMake))); + final make = ref.watch( + getSearchSuggestionsProvider(const SearchSuggestionArgs(type: SearchSuggestionType.cameraMake)), + ); final models = ref.watch( getSearchSuggestionsProvider( @@ -35,7 +37,7 @@ class CameraPicker extends HookConsumerWidget { AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(), _ => [], }, - label: const Text('make').tr(), + label: Text(context.t.make), controller: makeTextController, leadingIcon: const Icon(Icons.photo_camera_rounded), onSelected: (value) { @@ -54,7 +56,7 @@ class CameraPicker extends HookConsumerWidget { AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(), _ => [], }, - label: const Text('model').tr(), + label: Text(context.t.model), controller: modelTextController, leadingIcon: const Icon(Icons.camera), onSelected: (value) { diff --git a/mobile/lib/widgets/search/search_filter/display_option_picker.dart b/mobile/lib/widgets/search/search_filter/display_option_picker.dart index a64eab7b71..83ddc98107 100644 --- a/mobile/lib/widgets/search/search_filter/display_option_picker.dart +++ b/mobile/lib/widgets/search/search_filter/display_option_picker.dart @@ -1,6 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; enum DisplayOption { notInAlbum, favorite, archive } @@ -23,7 +23,7 @@ class DisplayOptionPicker extends HookWidget { shrinkWrap: true, children: [ CheckboxListTile( - title: const Text('search_filter_display_option_not_in_album').tr(), + title: Text(context.t.search_filter_display_option_not_in_album), value: options.value[DisplayOption.notInAlbum], onChanged: (bool? value) { options.value = {...options.value, DisplayOption.notInAlbum: value!}; @@ -31,7 +31,7 @@ class DisplayOptionPicker extends HookWidget { }, ), CheckboxListTile( - title: const Text('favorite').tr(), + title: Text(context.t.favorite), value: options.value[DisplayOption.favorite], onChanged: (value) { options.value = {...options.value, DisplayOption.favorite: value!}; @@ -39,7 +39,7 @@ class DisplayOptionPicker extends HookWidget { }, ), CheckboxListTile( - title: const Text('archive').tr(), + title: Text(context.t.archive), value: options.value[DisplayOption.archive], onChanged: (value) { options.value = {...options.value, DisplayOption.archive: value!}; diff --git a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart index 1c338b8828..a74ea41c38 100644 --- a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart +++ b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart @@ -1,6 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class FilterBottomSheetScaffold extends StatelessWidget { const FilterBottomSheetScaffold({ @@ -46,7 +46,7 @@ class FilterBottomSheetScaffold extends StatelessWidget { onClear(); context.pop(); }, - child: const Text('clear').tr(), + child: Text(context.t.clear), ), if (onSearch != null) const SizedBox(width: 8), if (onSearch != null) @@ -56,7 +56,7 @@ class FilterBottomSheetScaffold extends StatelessWidget { onSearch!(); context.pop(); }, - child: const Text('search_filter_apply').tr(), + child: Text(context.t.search_filter_apply), ), ], ), diff --git a/mobile/lib/widgets/search/search_filter/location_picker.dart b/mobile/lib/widgets/search/search_filter/location_picker.dart index f521a50f35..ab35145a28 100644 --- a/mobile/lib/widgets/search/search_filter/location_picker.dart +++ b/mobile/lib/widgets/search/search_filter/location_picker.dart @@ -1,7 +1,7 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/search_filter.provider.dart'; import 'package:immich_mobile/widgets/search/search_filter/common/dropdown.dart'; @@ -61,7 +61,7 @@ class LocationPicker extends HookConsumerWidget { AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(), _ => [], }, - label: const Text('country').tr(), + label: Text(context.t.country), controller: countryTextController, onSelected: (value) { if (value.toString() == selectedCountry.value) { @@ -80,7 +80,7 @@ class LocationPicker extends HookConsumerWidget { AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(), _ => [], }, - label: const Text('state').tr(), + label: Text(context.t.state), controller: stateTextController, onSelected: (value) { if (value.toString() == selectedState.value) { @@ -98,7 +98,7 @@ class LocationPicker extends HookConsumerWidget { AsyncData(:final value) => value.map((e) => DropdownMenuEntry(value: e, label: e)).toList(), _ => [], }, - label: const Text('city').tr(), + label: Text(context.t.city), controller: cityTextController, onSelected: (value) { selectedCity.value = value.toString(); diff --git a/mobile/lib/widgets/search/search_filter/media_type_picker.dart b/mobile/lib/widgets/search/search_filter/media_type_picker.dart index ac89de8190..dd0767a97a 100644 --- a/mobile/lib/widgets/search/search_filter/media_type_picker.dart +++ b/mobile/lib/widgets/search/search_filter/media_type_picker.dart @@ -1,7 +1,7 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class MediaTypePicker extends HookWidget { const MediaTypePicker({super.key, required this.onSelect, this.filter}); @@ -21,9 +21,9 @@ class MediaTypePicker extends HookWidget { groupValue: selectedMediaType.value, child: Column( children: [ - RadioListTile(key: const Key("all"), title: const Text("all").tr(), value: AssetType.other), - RadioListTile(key: const Key("image"), title: const Text("image").tr(), value: AssetType.image), - RadioListTile(key: const Key("video"), title: const Text("video").tr(), value: AssetType.video), + RadioListTile(key: const Key("all"), title: Text(context.t.all), value: AssetType.other), + RadioListTile(key: const Key("image"), title: Text(context.t.image), value: AssetType.image), + RadioListTile(key: const Key("video"), title: Text(context.t.video), value: AssetType.video), ], ), ); diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index 0732b30a63..ee738265b2 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -1,112 +1,112 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/string_extensions.dart'; -import 'package:immich_mobile/pages/common/large_leading_tile.dart'; -import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:immich_mobile/widgets/common/search_field.dart'; - -class PeoplePicker extends HookConsumerWidget { - const PeoplePicker({super.key, required this.onSelect, this.filter}); - - final Function(Set) onSelect; - final Set? filter; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final formFocus = useFocusNode(); - const imageSize = 60.0; - final searchQuery = useState(''); - final people = ref.watch(getAllPeopleProvider); - final selectedPeople = useState>(filter ?? {}); - - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: SearchField( - focusNode: formFocus, - onChanged: (value) => searchQuery.value = value, - onTapOutside: (_) => formFocus.unfocus(), - filled: true, - hintText: 'filter_people'.tr(), - ), - ), - Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0), - child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1), - ), - Expanded( - child: people.widgetWhen( - onData: (people) { - final filtered = people - .where( - (person) => person.name.toLowerCase().removeDiacritics().contains( - searchQuery.value.toLowerCase().removeDiacritics(), - ), - ) - .toList(); - return ListView.builder( - shrinkWrap: true, - itemCount: filtered.length, - padding: const EdgeInsets.all(8), - itemBuilder: (context, index) { - final person = filtered[index]; - final isSelected = selectedPeople.value.contains(person); - - return Padding( - key: ValueKey(person.id), - padding: const EdgeInsets.only(bottom: 2.0), - child: LargeLeadingTile( - title: Text( - person.name, - style: context.textTheme.bodyLarge?.copyWith( - fontSize: 20, - fontWeight: FontWeight.w500, - color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, - ), - ), - leading: SizedBox( - height: imageSize, - child: Material( - shape: const CircleBorder(side: BorderSide.none), - elevation: 3, - child: CircleAvatar( - key: ValueKey(person.id), - maxRadius: imageSize / 2, - backgroundImage: RemoteImageProvider( - url: getFaceThumbnailUrl(person.id, updatedAt: person.updatedAt), - ), - ), - ), - ), - onTap: () { - if (selectedPeople.value.contains(person)) { - selectedPeople.value.remove(person); - } else { - selectedPeople.value.add(person); - } - - selectedPeople.value = {...selectedPeople.value}; - onSelect(selectedPeople.value); - }, - selected: isSelected, - selectedTileColor: context.primaryColor, - tileColor: context.primaryColor.withAlpha(25), - ), - ); - }, - ); - }, - ), - ), - ], - ); - } -} +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; + +class PeoplePicker extends HookConsumerWidget { + const PeoplePicker({super.key, required this.onSelect, this.filter}); + + final Function(Set) onSelect; + final Set? filter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formFocus = useFocusNode(); + const imageSize = 60.0; + final searchQuery = useState(''); + final people = ref.watch(getAllPeopleProvider); + final selectedPeople = useState>(filter ?? {}); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: SearchField( + focusNode: formFocus, + onChanged: (value) => searchQuery.value = value, + onTapOutside: (_) => formFocus.unfocus(), + filled: true, + hintText: context.t.filter_people, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0), + child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1), + ), + Expanded( + child: people.widgetWhen( + onData: (people) { + final filtered = people + .where( + (person) => person.name.toLowerCase().removeDiacritics().contains( + searchQuery.value.toLowerCase().removeDiacritics(), + ), + ) + .toList(); + return ListView.builder( + shrinkWrap: true, + itemCount: filtered.length, + padding: const EdgeInsets.all(8), + itemBuilder: (context, index) { + final person = filtered[index]; + final isSelected = selectedPeople.value.contains(person); + + return Padding( + key: ValueKey(person.id), + padding: const EdgeInsets.only(bottom: 2.0), + child: LargeLeadingTile( + title: Text( + person.name.nullIfEmpty ?? context.t.no_name, + style: context.textTheme.bodyLarge?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w500, + color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + ), + leading: SizedBox( + height: imageSize, + child: Material( + shape: const CircleBorder(side: BorderSide.none), + elevation: 3, + child: CircleAvatar( + key: ValueKey(person.id), + maxRadius: imageSize / 2, + backgroundImage: RemoteImageProvider( + url: getFaceThumbnailUrl(person.id, updatedAt: person.updatedAt), + ), + ), + ), + ), + onTap: () { + if (selectedPeople.value.contains(person)) { + selectedPeople.value.remove(person); + } else { + selectedPeople.value.add(person); + } + + selectedPeople.value = {...selectedPeople.value}; + onSelect(selectedPeople.value); + }, + selected: isSelected, + selectedTileColor: context.primaryColor, + tileColor: context.primaryColor.withAlpha(25), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/search/search_filter/star_rating_picker.dart b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart index 864ddf2591..643f4f167c 100644 --- a/mobile/lib/widgets/search/search_filter/star_rating_picker.dart +++ b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/utils/option.dart'; @@ -28,7 +28,7 @@ class StarRatingPicker extends HookWidget { 6, (index) => RadioListTile( key: Key("star_$index"), - title: Text('rating_count'.t(args: {'count': index})), + title: Text(context.t.rating_count(count: index)), value: index, ), ), diff --git a/mobile/lib/widgets/search/search_row_title.dart b/mobile/lib/widgets/search/search_row_title.dart index dc0a4ba6cb..cf4cc62c18 100644 --- a/mobile/lib/widgets/search/search_row_title.dart +++ b/mobile/lib/widgets/search/search_row_title.dart @@ -1,6 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class SearchRowTitle extends StatelessWidget { const SearchRowTitle({super.key, required this.onViewAllPressed, required this.title}); @@ -17,9 +17,9 @@ class SearchRowTitle extends StatelessWidget { TextButton( onPressed: onViewAllPressed, child: Text( - 'search_page_view_all_button', + context.t.search_page_view_all_button, style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor), - ).tr(), + ), ), ], ); diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index bf8673ccc5..44a992345f 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; @@ -32,11 +32,11 @@ class AdvancedSettings extends HookConsumerWidget { final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final isManageMediaSupported = useState(false); final manageMediaAndroidPermission = useState(false); - final levelId = useState(ref.read(appConfigProvider).logLevel.index); - final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote); + final levelId = useState(ref.watch(appConfigProvider).logLevel.index); + final preferRemote = useState(ref.watch(appConfigProvider).image.preferRemote); useValueChanged( preferRemote.value, - (_, __) => unawaited(ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value)), + (_, _) => unawaited(ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value)), ); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); @@ -44,7 +44,7 @@ class AdvancedSettings extends HookConsumerWidget { useValueChanged( levelId.value, - (_, __) => unawaited(LogService.I.setLogLevel(Level.LEVELS[levelId.value].toLogLevel())), + (_, _) => unawaited(LogService.I.setLogLevel(Level.LEVELS[levelId.value].toLogLevel())), ); Future checkAndroidVersion() async { @@ -60,7 +60,7 @@ class AdvancedSettings extends HookConsumerWidget { useEffect(() { unawaited(() async { isManageMediaSupported.value = await checkAndroidVersion(); - if (isManageMediaSupported.value) { + if (isManageMediaSupported.value && context.mounted) { manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission(); } }()); @@ -71,8 +71,8 @@ class AdvancedSettings extends HookConsumerWidget { SettingsSwitchListTile( enabled: true, valueNotifier: advancedTroubleshooting, - title: "advanced_settings_troubleshooting_title".tr(), - subtitle: "advanced_settings_troubleshooting_subtitle".tr(), + title: context.t.advanced_settings_troubleshooting_title, + subtitle: context.t.advanced_settings_troubleshooting_subtitle, ), if (isManageMediaSupported.value) Column( @@ -80,8 +80,8 @@ class AdvancedSettings extends HookConsumerWidget { SettingsSwitchListTile( enabled: true, valueNotifier: manageLocalMediaAndroid, - title: "advanced_settings_sync_remote_deletions_title".tr(), - subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(), + title: context.t.advanced_settings_sync_remote_deletions_title, + subtitle: context.t.advanced_settings_sync_remote_deletions_subtitle, onChanged: (value) async { if (value) { final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission(); @@ -91,9 +91,9 @@ class AdvancedSettings extends HookConsumerWidget { }, ), SettingsActionTile( - title: "manage_media_access_title".tr(), - statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(), - subtitle: "manage_media_access_rationale".tr(), + title: context.t.manage_media_access_title, + statusText: manageMediaAndroidPermission.value ? context.t.allowed : context.t.not_allowed, + subtitle: context.t.manage_media_access_rationale, statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value ? const Color.fromARGB(255, 243, 188, 106) : null, @@ -105,7 +105,7 @@ class AdvancedSettings extends HookConsumerWidget { ], ), SettingsSliderListTile( - text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}), + text: context.t.advanced_settings_log_level_title(level: logLevel), valueNotifier: levelId, maxValue: 8, minValue: 1, @@ -114,15 +114,15 @@ class AdvancedSettings extends HookConsumerWidget { ), SettingsSwitchListTile( valueNotifier: preferRemote, - title: "advanced_settings_prefer_remote_title".tr(), - subtitle: "advanced_settings_prefer_remote_subtitle".tr(), + title: context.t.advanced_settings_prefer_remote_title, + subtitle: context.t.advanced_settings_prefer_remote_subtitle, ), const CustomProxyHeaderSettings(), const SslClientCertSettings(), SettingsSwitchListTile( valueNotifier: readonlyModeEnabled, - title: "advanced_settings_readonly_mode_title".tr(), - subtitle: "advanced_settings_readonly_mode_subtitle".tr(), + title: context.t.advanced_settings_readonly_mode_title, + subtitle: context.t.advanced_settings_readonly_mode_subtitle, onChanged: (value) { readonlyModeEnabled.value = value; ref.read(readonlyModeProvider.notifier).setReadonlyMode(value); @@ -130,7 +130,7 @@ class AdvancedSettings extends HookConsumerWidget { SnackBar( duration: const Duration(seconds: 2), content: Text( - (value ? "readonly_mode_enabled" : "readonly_mode_disabled").tr(), + value ? context.t.readonly_mode_enabled : context.t.readonly_mode_disabled, style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), ), ), @@ -138,7 +138,7 @@ class AdvancedSettings extends HookConsumerWidget { }, ), ListTile( - title: Text("advanced_settings_clear_image_cache".tr(), style: const TextStyle(fontWeight: FontWeight.w500)), + title: Text(context.t.advanced_settings_clear_image_cache, style: const TextStyle(fontWeight: FontWeight.w500)), leading: const Icon(Icons.playlist_remove_rounded), onTap: () async { final int clearedBytes; @@ -153,7 +153,7 @@ class AdvancedSettings extends HookConsumerWidget { SnackBar( duration: const Duration(seconds: 2), content: Text( - "advanced_settings_clear_image_cache_error".tr(), + context.t.advanced_settings_clear_image_cache_error, style: context.textTheme.bodyLarge?.copyWith(color: context.themeData.colorScheme.error), ), ), @@ -171,7 +171,7 @@ class AdvancedSettings extends HookConsumerWidget { SnackBar( duration: const Duration(seconds: 2), content: Text( - "advanced_settings_clear_image_cache_success".tr(namedArgs: {'size': clearedMB}), + context.t.advanced_settings_clear_image_cache_success(size: clearedMB), style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), ), ), diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart index a5ced2ef5f..a68ad0fe5e 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -34,20 +34,14 @@ class GroupSettings extends HookConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingGroupTitle( - title: "asset_list_group_by_sub_title".t(context: context), - icon: Icons.group_work_outlined, - ), + SettingGroupTitle(title: context.t.asset_list_group_by_sub_title, icon: Icons.group_work_outlined), SettingsRadioListTile( groups: [ SettingsRadioGroup( - title: 'asset_list_layout_settings_group_by_month_day'.t(context: context), + title: context.t.asset_list_layout_settings_group_by_month_day, value: GroupAssetsBy.day, ), - SettingsRadioGroup( - title: 'month'.t(context: context), - value: GroupAssetsBy.month, - ), + SettingsRadioGroup(title: context.t.month, value: GroupAssetsBy.month), ], groupBy: groupBy.value, onRadioChanged: changeGroupValue, diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart index eda0d819d5..651cc43656 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; @@ -15,21 +14,18 @@ class LayoutSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tilesPerRow = useState(ref.read(appConfigProvider.select((s) => s.timeline.tilesPerRow))); - useValueChanged(tilesPerRow.value, (_, __) { + final tilesPerRow = useState(ref.watch(appConfigProvider.select((s) => s.timeline.tilesPerRow))); + useValueChanged(tilesPerRow.value, (_, _) { unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, tilesPerRow.value)); }); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingGroupTitle( - title: "asset_list_layout_sub_title".t(context: context), - icon: Icons.view_module_outlined, - ), + SettingGroupTitle(title: context.t.asset_list_layout_sub_title, icon: Icons.view_module_outlined), SettingsSliderListTile( valueNotifier: tilesPerRow, - text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}), + text: context.t.theme_setting_asset_list_tiles_per_row_title(count: tilesPerRow.value), label: "${tilesPerRow.value}", maxValue: 6, minValue: 2, diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart index 1f3f4bbcf0..08fe897c5f 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart'; @@ -21,7 +21,7 @@ class AssetListSettings extends HookConsumerWidget { final assetListSetting = [ SettingsSwitchListTile( valueNotifier: storageIndicator, - title: 'theme_setting_asset_list_storage_indicator_title'.tr(), + title: context.t.theme_setting_asset_list_storage_indicator_title, onChanged: (value) { unawaited(ref.read(settingsProvider).write(.timelineStorageIndicator, value)); ref.invalidate(appSettingsServiceProvider); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart index e3173dcdc7..5b5917addc 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; @@ -14,8 +14,8 @@ class ImageViewerQualitySetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal); - useValueChanged(isOriginal.value, (_, __) { + final isOriginal = useState(ref.watch(appConfigProvider).image.loadOriginal); + useValueChanged(isOriginal.value, (_, _) { unawaited(ref.read(settingsProvider).write(.imageLoadOriginal, isOriginal.value)); }); @@ -23,14 +23,14 @@ class ImageViewerQualitySetting extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SettingGroupTitle( - title: "photos".t(context: context), + title: context.t.photos, icon: Icons.image_outlined, - subtitle: "setting_image_viewer_help".t(context: context), + subtitle: context.t.setting_image_viewer_help, ), SettingsSwitchListTile( valueNotifier: isOriginal, - title: "setting_image_viewer_original_title".t(context: context), - subtitle: "setting_image_viewer_original_subtitle".t(context: context), + title: context.t.setting_image_viewer_original_title, + subtitle: context.t.setting_image_viewer_original_subtitle, onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), ], diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart index c785e8fed4..f8dc6b0e1b 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @@ -13,19 +13,19 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate); - useValueChanged(tapToNavigate.value, (_, __) { + final tapToNavigate = useState(ref.watch(appConfigProvider).viewer.tapToNavigate); + useValueChanged(tapToNavigate.value, (_, _) { unawaited(ref.read(settingsProvider).write(.viewerTapToNavigate, tapToNavigate.value)); }); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingsSubTitle(title: "setting_image_navigation_title".tr()), + SettingsSubTitle(title: context.t.setting_image_navigation_title), SettingsSwitchListTile( valueNotifier: tapToNavigate, - title: "setting_image_navigation_enable_title".tr(), - subtitle: "setting_image_navigation_enable_subtitle".tr(), + title: context.t.setting_image_navigation_enable_title, + subtitle: context.t.setting_image_navigation_enable_subtitle, ), ], ); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart index ec52d23ca8..72d1685d33 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart'; @@ -17,62 +17,50 @@ class SlideshowSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final slideshow = ref.read(appConfigProvider).slideshow; + final slideshow = ref.watch(appConfigProvider).slideshow; final useRepeat = useState(slideshow.repeat); final useDuration = useState(slideshow.duration); final useLook = useState(slideshow.look); final useDirection = useState(slideshow.direction); - useValueChanged(useRepeat.value, (_, __) { + useValueChanged(useRepeat.value, (_, _) { unawaited(ref.read(settingsProvider).write(.slideshowRepeat, useRepeat.value)); }); - useValueChanged(useDuration.value, (_, __) { + useValueChanged(useDuration.value, (_, _) { unawaited(ref.read(settingsProvider).write(.slideshowDuration, useDuration.value)); }); - useValueChanged(useLook.value, (_, __) { + useValueChanged(useLook.value, (_, _) { unawaited(ref.read(settingsProvider).write(.slideshowLook, useLook.value)); }); - useValueChanged(useDirection.value, (_, __) { + useValueChanged(useDirection.value, (_, _) { unawaited(ref.read(settingsProvider).write(.slideshowDirection, useDirection.value)); }); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingGroupTitle( - title: 'slideshow'.t(context: context), - icon: Icons.slideshow_outlined, - ), + SettingGroupTitle(title: context.t.slideshow, icon: Icons.slideshow_outlined), SettingsSwitchListTile( valueNotifier: useRepeat, - title: "slideshow_repeat".t(context: context), - subtitle: "slideshow_repeat_description".t(context: context), + title: context.t.slideshow_repeat, + subtitle: context.t.slideshow_repeat_description, ), SettingsSliderListTile( valueNotifier: useDuration, - text: "duration".t(context: context), + text: context.t.duration, minValue: 5, noDivisons: 5, maxValue: 30, ), Padding( padding: const EdgeInsets.only(top: 20), - child: SettingsSubTitle(title: 'look'.t(context: context)), + child: SettingsSubTitle(title: context.t.look), ), SettingsRadioListTile( groups: [ - SettingsRadioGroup( - title: 'contain'.t(context: context), - value: SlideshowLook.contain, - ), - SettingsRadioGroup( - title: 'cover'.t(context: context), - value: SlideshowLook.cover, - ), - SettingsRadioGroup( - title: 'blurred_background'.t(context: context), - value: SlideshowLook.blurredBackground, - ), + SettingsRadioGroup(title: context.t.contain, value: SlideshowLook.contain), + SettingsRadioGroup(title: context.t.cover, value: SlideshowLook.cover), + SettingsRadioGroup(title: context.t.blurred_background, value: SlideshowLook.blurredBackground), ], groupBy: useLook.value, onRadioChanged: (value) { @@ -83,24 +71,15 @@ class SlideshowSettings extends HookConsumerWidget { ), Padding( padding: const EdgeInsets.only(top: 20), - child: SettingsSubTitle(title: 'direction'.t(context: context)), + child: SettingsSubTitle(title: context.t.direction), ), Padding( padding: const EdgeInsets.only(bottom: 32), child: SettingsRadioListTile( groups: [ - SettingsRadioGroup( - title: 'forward'.t(context: context), - value: SlideshowDirection.forward, - ), - SettingsRadioGroup( - title: 'backward'.t(context: context), - value: SlideshowDirection.backward, - ), - SettingsRadioGroup( - title: 'shuffle'.t(context: context), - value: SlideshowDirection.shuffle, - ), + SettingsRadioGroup(title: context.t.forward, value: SlideshowDirection.forward), + SettingsRadioGroup(title: context.t.backward, value: SlideshowDirection.backward), + SettingsRadioGroup(title: context.t.shuffle, value: SlideshowDirection.shuffle), ], groupBy: useDirection.value, onRadioChanged: (value) { diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart index 2b302e0427..bb14d4b327 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @@ -13,42 +13,39 @@ class VideoViewerSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final viewer = ref.read(appConfigProvider).viewer; + final viewer = ref.watch(appConfigProvider).viewer; final useAutoPlayVideo = useState(viewer.autoPlayVideo); final useLoopVideo = useState(viewer.loopVideo); final useOriginalVideo = useState(viewer.loadOriginalVideo); - useValueChanged(useAutoPlayVideo.value, (_, __) { + useValueChanged(useAutoPlayVideo.value, (_, _) { unawaited(ref.read(settingsProvider).write(.viewerAutoPlayVideo, useAutoPlayVideo.value)); }); - useValueChanged(useLoopVideo.value, (_, __) { + useValueChanged(useLoopVideo.value, (_, _) { unawaited(ref.read(settingsProvider).write(.viewerLoopVideo, useLoopVideo.value)); }); - useValueChanged(useOriginalVideo.value, (_, __) { + useValueChanged(useOriginalVideo.value, (_, _) { unawaited(ref.read(settingsProvider).write(.viewerLoadOriginalVideo, useOriginalVideo.value)); }); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingGroupTitle( - title: "videos".t(context: context), - icon: Icons.video_camera_back_outlined, - ), + SettingGroupTitle(title: context.t.videos, icon: Icons.video_camera_back_outlined), SettingsSwitchListTile( valueNotifier: useAutoPlayVideo, - title: "setting_video_viewer_auto_play_title".t(context: context), - subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context), + title: context.t.setting_video_viewer_auto_play_title, + subtitle: context.t.setting_video_viewer_auto_play_subtitle, ), SettingsSwitchListTile( valueNotifier: useLoopVideo, - title: "setting_video_viewer_looping_title".t(context: context), - subtitle: "loop_videos_description".t(context: context), + title: context.t.setting_video_viewer_looping_title, + subtitle: context.t.loop_videos_description, ), SettingsSwitchListTile( valueNotifier: useOriginalVideo, - title: "setting_video_viewer_original_video_title".t(context: context), - subtitle: "setting_video_viewer_original_video_subtitle".t(context: context), + title: context.t.setting_video_viewer_original_video_title, + subtitle: context.t.setting_video_viewer_original_video_subtitle, ), ], ); diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart index f0f6435f90..ae9ff87a91 100644 --- a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart @@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; @@ -19,33 +18,24 @@ import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/setting_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; -class DriftBackupSettings extends ConsumerWidget { +class DriftBackupSettings extends StatelessWidget { const DriftBackupSettings({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return SettingsSubPageScaffold( settings: [ - SettingGroupTitle( - title: "network_requirements".t(context: context), - icon: Icons.cell_tower, - ), + SettingGroupTitle(title: context.t.network_requirements, icon: Icons.cell_tower), const _UseCellularForVideosButton(), const _UseCellularForPhotosButton(), if (CurrentPlatform.isAndroid) ...[ const Divider(), - SettingGroupTitle( - title: "background_options".t(context: context), - icon: Icons.charging_station_rounded, - ), + SettingGroupTitle(title: context.t.background_options, icon: Icons.charging_station_rounded), const _BackupOnlyWhenChargingButton(), const _BackupDelaySlider(), ], const Divider(), - SettingGroupTitle( - title: "backup_albums_sync".t(context: context), - icon: Icons.sync, - ), + SettingGroupTitle(title: context.t.backup_albums_sync, icon: Icons.sync), const _AlbumSyncActionButton(), ], ); @@ -68,8 +58,9 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> }); try { - await ref.read(backgroundSyncProvider).syncLinkedAlbum(); - await ref.read(backgroundSyncProvider).syncRemote(); + final backgroundSync = ref.read(backgroundSyncProvider); + await backgroundSync.syncLinkedAlbum(); + await backgroundSync.syncRemote(); } catch (_) { } finally { Future.delayed(const Duration(seconds: 1), () { @@ -107,8 +98,8 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> Column( children: [ SettingListTile( - title: "sync_albums".t(context: context), - subtitle: "sync_upload_album_setting_subtitle".t(context: context), + title: context.t.sync_albums, + subtitle: context.t.sync_upload_album_setting_subtitle, trailing: Switch( value: albumSyncEnable, onChanged: (bool newValue) async { @@ -130,8 +121,8 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> ? SettingListTile( onTap: _manualSyncAlbums, contentPadding: const EdgeInsets.only(left: 32, right: 16), - title: "organize_into_albums".t(context: context), - subtitle: "organize_into_albums_description".t(context: context), + title: context.t.organize_into_albums, + subtitle: context.t.organize_into_albums_description, trailing: isAlbumSyncInProgress ? const SizedBox( width: 32, @@ -160,15 +151,15 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> class _BackupSwitchTile extends ConsumerWidget { final SettingsKey metadataKey; final bool Function(AppConfig) selector; - final String titleKey; - final String subtitleKey; + final String title; + final String subtitle; final void Function(bool)? onChanged; const _BackupSwitchTile({ required this.metadataKey, required this.selector, - required this.titleKey, - required this.subtitleKey, + required this.title, + required this.subtitle, this.onChanged, }); @@ -178,8 +169,8 @@ class _BackupSwitchTile extends ConsumerWidget { return Padding( padding: const EdgeInsets.only(left: 8.0), child: SettingListTile( - title: titleKey.t(context: context), - subtitle: subtitleKey.t(context: context), + title: title, + subtitle: subtitle, trailing: Switch( value: value, onChanged: (bool newValue) async { @@ -200,8 +191,8 @@ class _UseCellularForVideosButton extends StatelessWidget { return _BackupSwitchTile( metadataKey: SettingsKey.backupUseCellularForVideos, selector: (c) => c.backup.useCellularForVideos, - titleKey: "videos", - subtitleKey: "network_requirement_videos_upload", + title: context.t.videos, + subtitle: context.t.network_requirement_videos_upload, ); } } @@ -214,8 +205,8 @@ class _UseCellularForPhotosButton extends StatelessWidget { return _BackupSwitchTile( metadataKey: SettingsKey.backupUseCellularForPhotos, selector: (c) => c.backup.useCellularForPhotos, - titleKey: "photos", - subtitleKey: "network_requirement_photos_upload", + title: context.t.photos, + subtitle: context.t.network_requirement_photos_upload, ); } } @@ -225,12 +216,12 @@ class _BackupOnlyWhenChargingButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final fgService = ref.read(backgroundWorkerFgServiceProvider); + final fgService = ref.watch(backgroundWorkerFgServiceProvider); return _BackupSwitchTile( metadataKey: SettingsKey.backupRequireCharging, selector: (c) => c.backup.requireCharging, - titleKey: "charging", - subtitleKey: "charging_requirement_mobile_backup", + title: context.t.charging, + subtitle: context.t.charging_requirement_mobile_backup, onChanged: (value) { unawaited(fgService.configure(requireCharging: value)); }, diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index 58a044cea4..d97be93ac4 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -3,16 +3,17 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; +import 'package:immich_mobile/domain/services/local_album.service.dart'; +import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -45,9 +46,7 @@ class SyncStatusAndActions extends HookConsumerWidget { return; } - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("Database file not found".t(context: context))), - ); + context.scaffoldMessenger.showSnackBar(const SnackBar(content: Text('Database file not found'))); return; } @@ -77,17 +76,13 @@ class SyncStatusAndActions extends HookConsumerWidget { return; } - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("Database exported successfully".t(context: context))), - ); + context.scaffoldMessenger.showSnackBar(const SnackBar(content: Text('Database exported successfully'))); } catch (e) { if (!context.mounted) { return; } - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("Failed to export database: $e".t(context: context))), - ); + context.scaffoldMessenger.showSnackBar(SnackBar(content: Text('Failed to export database: $e'))); } } @@ -138,10 +133,10 @@ class SyncStatusAndActions extends HookConsumerWidget { const _SyncStatsCounts(), const Divider(height: 10), const SizedBox(height: 16), - SettingGroupTitle(title: "jobs".t(context: context)), + SettingGroupTitle(title: context.t.jobs), SettingListTile( - title: "sync_local".t(context: context), - subtitle: "tap_to_run_job".t(context: context), + title: context.t.sync_local, + subtitle: context.t.tap_to_run_job, leading: const Icon(Icons.sync), trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus), onTap: () { @@ -149,26 +144,26 @@ class SyncStatusAndActions extends HookConsumerWidget { }, ), SettingListTile( - title: "sync_remote".t(context: context), - subtitle: "tap_to_run_job".t(context: context), + title: context.t.sync_remote, + subtitle: context.t.tap_to_run_job, leading: const Icon(Icons.cloud_sync), trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus), onTap: () { unawaited(ref.read(backgroundSyncProvider).syncRemote()); }, ), - if (CurrentPlatform.isIOS && serverVersion.isAtLeast(major: 2, minor: 5)) + if (CurrentPlatform.isIOS && serverVersion.supports(.cloudIdMetadata)) SettingListTile( - title: "Sync Cloud Ids".t(context: context), + title: 'Sync Cloud Ids', leading: const Icon(Icons.cloud_circle_rounded), - subtitle: "tap_to_run_job".t(context: context), + subtitle: context.t.tap_to_run_job, trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).cloudIdSyncStatus), - onTap: ref.read(backgroundSyncProvider).syncCloudIds, + onTap: ref.watch(backgroundSyncProvider).syncCloudIds, ), SettingListTile( - title: "hash_asset".t(context: context), + title: context.t.hash_asset, leading: const Icon(Icons.tag), - subtitle: "tap_to_run_job".t(context: context), + subtitle: context.t.tap_to_run_job, trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus), onTap: () { unawaited(ref.read(backgroundSyncProvider).hashAssets()); @@ -176,27 +171,21 @@ class SyncStatusAndActions extends HookConsumerWidget { ), const Divider(height: 1), const SizedBox(height: 16), - SettingGroupTitle(title: "actions".t(context: context)), + SettingGroupTitle(title: context.t.actions), ListTile( - title: Text( - "clear_file_cache".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), + title: Text(context.t.clear_file_cache, style: const TextStyle(fontWeight: FontWeight.w500)), leading: const Icon(Icons.playlist_remove_rounded), onTap: clearFileCache, ), ListTile( - title: Text( - "export_database".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("export_database_description".t(context: context)), + title: Text(context.t.export_database, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(context.t.export_database_description), leading: const Icon(Icons.download), onTap: exportDatabase, ), ListTile( title: Text( - "reset_sqlite".t(context: context), + context.t.reset_sqlite, style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), ), leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), @@ -230,10 +219,11 @@ class _SyncStatsCounts extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final db = ref.watch(driftProvider); final assetService = ref.watch(assetServiceProvider); - final localAlbumService = ref.watch(localAlbumServiceProvider); + final localAlbumService = LocalAlbumService(db.localAlbumRepository); final remoteAlbumService = ref.watch(remoteAlbumServiceProvider); - final memoryService = ref.watch(driftMemoryServiceProvider); + final memoryService = DriftMemoryService(db.memoryRepository); final appSettingsService = ref.watch(appSettingsServiceProvider); Future> loadCounts() async { @@ -282,7 +272,7 @@ class _SyncStatsCounts extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingGroupTitle(title: "assets".t(context: context)), + SettingGroupTitle(title: context.t.assets), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), // 1. Wrap in IntrinsicHeight @@ -295,24 +285,16 @@ class _SyncStatsCounts extends ConsumerWidget { spacing: 8.0, children: [ Expanded( - child: EntityCountTile( - label: "local".t(context: context), - count: localAssetCount, - icon: Icons.smartphone, - ), + child: EntityCountTile(label: context.t.local, count: localAssetCount, icon: Icons.smartphone), ), Expanded( - child: EntityCountTile( - label: "remote".t(context: context), - count: remoteAssetCount, - icon: Icons.cloud, - ), + child: EntityCountTile(label: context.t.remote, count: remoteAssetCount, icon: Icons.cloud), ), ], ), ), ), - SettingGroupTitle(title: "albums".t(context: context)), + SettingGroupTitle(title: context.t.albums), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: IntrinsicHeight( @@ -323,24 +305,16 @@ class _SyncStatsCounts extends ConsumerWidget { spacing: 8.0, children: [ Expanded( - child: EntityCountTile( - label: "local".t(context: context), - count: localAlbumCount, - icon: Icons.smartphone, - ), + child: EntityCountTile(label: context.t.local, count: localAlbumCount, icon: Icons.smartphone), ), Expanded( - child: EntityCountTile( - label: "remote".t(context: context), - count: remoteAlbumCount, - icon: Icons.cloud, - ), + child: EntityCountTile(label: context.t.remote, count: remoteAlbumCount, icon: Icons.cloud), ), ], ), ), ), - SettingGroupTitle(title: "other".t(context: context)), + SettingGroupTitle(title: context.t.other), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: IntrinsicHeight( @@ -351,18 +325,10 @@ class _SyncStatsCounts extends ConsumerWidget { spacing: 8.0, children: [ Expanded( - child: EntityCountTile( - label: "memories".t(context: context), - count: memoryCount, - icon: Icons.calendar_today, - ), + child: EntityCountTile(label: context.t.memories, count: memoryCount, icon: Icons.calendar_today), ), Expanded( - child: EntityCountTile( - label: "hashed_assets".t(context: context), - count: localHashedCount, - icon: Icons.tag, - ), + child: EntityCountTile(label: context.t.hashed_assets, count: localHashedCount, icon: Icons.tag), ), ], ), @@ -371,7 +337,7 @@ class _SyncStatsCounts extends ConsumerWidget { // To be removed once the experimental feature is stable if (CurrentPlatform.isAndroid && appSettingsService.getSetting(AppSettingsEnum.manageLocalMediaAndroid)) ...[ - SettingGroupTitle(title: "trash".t(context: context)), + SettingGroupTitle(title: context.t.trash), Consumer( builder: (context, ref, _) { final counts = ref.watch(trashedAssetsCountProvider); @@ -387,17 +353,13 @@ class _SyncStatsCounts extends ConsumerWidget { children: [ Expanded( child: EntityCountTile( - label: "local".t(context: context), + label: context.t.local, count: c.total, icon: Icons.delete_outline, ), ), Expanded( - child: EntityCountTile( - label: "hashed_assets".t(context: context), - count: c.hashed, - icon: Icons.tag, - ), + child: EntityCountTile(label: context.t.hashed_assets, count: c.hashed, icon: Icons.tag), ), ], ), diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart index 72e85ac821..07dfe95b21 100644 --- a/mobile/lib/widgets/settings/free_up_space_settings.dart +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -9,12 +8,13 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/cleanup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:intl/intl.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; class FreeUpSpaceSettings extends ConsumerStatefulWidget { @@ -39,11 +39,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState { } Future _initializeAlbumDefaults() async { + final notifier = ref.read(cleanupProvider.notifier); final albums = await ref.read(localAlbumProvider.future); final existingAlbumIds = albums.map((a) => a.id).toSet(); final albumsWithNames = albums.map((a) => (a.id, a.name)).toList(); - final notifier = ref.read(cleanupProvider.notifier); notifier.applyDefaultAlbumSelections(albumsWithNames); notifier.cleanupStaleAlbumIds(existingAlbumIds); } @@ -105,7 +105,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { lastDate: DateTime.now(), ); - if (picked != null) { + if (picked != null && context.mounted) { ref.read(cleanupProvider.notifier).setSelectedDate(picked); setState(() => _hasScanned = false); } @@ -122,6 +122,10 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ref.read(hapticFeedbackProvider.notifier).mediumImpact(); await ref.read(cleanupProvider.notifier).scanAssets(); + if (!mounted) { + return; + } + final state = ref.read(cleanupProvider); setState(() { @@ -146,7 +150,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { _DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!), ); - if (confirmed != true) { + if (confirmed != true || !context.mounted) { return; } @@ -232,21 +236,21 @@ class _FreeUpSpaceSettingsState extends ConsumerState { final parts = []; if (state.keepMediaType == AssetKeepType.photosOnly) { - parts.add('all_photos'.t(context: context)); + parts.add(context.t.all_photos); } else if (state.keepMediaType == AssetKeepType.videosOnly) { - parts.add('all_videos'.t(context: context)); + parts.add(context.t.all_videos); } if (state.keepFavorites) { - parts.add('favorites'.t(context: context)); + parts.add(context.t.favorites); } if (state.keepAlbumIds.isNotEmpty) { - parts.add('keep_albums_count'.t(context: context, args: {'count': state.keepAlbumIds.length.toString()})); + parts.add(context.t.keep_albums_count(count: state.keepAlbumIds.length)); } if (parts.isEmpty) { - return 'none'.t(context: context); + return context.t.none; } return parts.join(', '); @@ -270,7 +274,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { borderRadius: const BorderRadius.all(Radius.circular(12)), border: Border.all(color: context.primaryColor.withValues(alpha: 0.25)), ), - child: Text('free_up_space_description'.t(context: context), style: context.textTheme.bodyMedium), + child: Text(context.t.free_up_space_description, style: context.textTheme.bodyMedium), ), ), @@ -303,7 +307,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant, ), title: Text( - 'keep_on_device'.t(context: context), + context.t.keep_on_device, style: context.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: hasKeepSettings ? context.colorScheme.primary : null, @@ -311,8 +315,8 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ), subtitle: Text( hasKeepSettings - ? 'keeping'.t(context: context, args: {'items': getKeepSettingsSummary()}) - : 'keep_on_device_hint'.t(context: context), + ? context.t.keeping(items: getKeepSettingsSummary()) + : context.t.keep_on_device_hint, style: context.textTheme.bodySmall?.copyWith( color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant, ), @@ -323,12 +327,12 @@ class _FreeUpSpaceSettingsState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('keep_description'.t(context: context), style: subtitleStyle), + Text(context.t.keep_description, style: subtitleStyle), const SizedBox(height: 4), SwitchListTile( contentPadding: EdgeInsets.zero, title: Text( - 'keep_favorites'.t(context: context), + context.t.keep_favorites, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), ), @@ -348,7 +352,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ), const SizedBox(height: 16), Text( - 'always_keep'.t(context: context), + context.t.always_keep, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), ), const SizedBox(height: 4), @@ -358,12 +362,12 @@ class _FreeUpSpaceSettingsState extends ConsumerState { const ButtonSegment(value: AssetKeepType.none, label: Text('—')), ButtonSegment( value: AssetKeepType.photosOnly, - label: Text('photos'.t(context: context)), + label: Text(context.t.photos), icon: const Icon(Icons.photo), ), ButtonSegment( value: AssetKeepType.videosOnly, - label: Text('videos'.t(context: context)), + label: Text(context.t.videos), icon: const Icon(Icons.videocam), ), ], @@ -377,8 +381,8 @@ class _FreeUpSpaceSettingsState extends ConsumerState { const SizedBox(height: 8), Text( state.keepMediaType == AssetKeepType.photosOnly - ? 'always_keep_photos_hint'.t(context: context) - : 'always_keep_videos_hint'.t(context: context), + ? context.t.always_keep_photos_hint + : context.t.always_keep_videos_hint, style: context.textTheme.bodySmall?.copyWith( color: context.colorScheme.onSurfaceVariant, ), @@ -403,13 +407,13 @@ class _FreeUpSpaceSettingsState extends ConsumerState { setState(() => _currentStep = CleanupStep.values[step]); } }, - controlsBuilder: (_, __) => const SizedBox.shrink(), + controlsBuilder: (_, _) => const SizedBox.shrink(), steps: [ // Step 1: Select Cutoff Date Step( stepStyle: styleForState(step1State), title: Text( - 'select_cutoff_date'.t(context: context), + context.t.select_cutoff_date, style: context.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: step1State == StepState.complete @@ -429,7 +433,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { content: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('cutoff_date_description'.t(context: context), style: subtitleStyle), + Text(context.t.cutoff_date_description, style: subtitleStyle), const SizedBox(height: 16), GridView.count( shrinkWrap: true, @@ -441,39 +445,39 @@ class _FreeUpSpaceSettingsState extends ConsumerState { children: [ _DatePresetCard( value: '30', - unit: 'cutoff_day'.t(context: context, args: {'count': '30'}), + unit: context.t.cutoff_day(count: 30), onTap: () => _setPresetDate(30), isSelected: _isPresetSelected(30), ), _DatePresetCard( value: '60', - unit: 'cutoff_day'.t(context: context, args: {'count': '60'}), + unit: context.t.cutoff_day(count: 60), onTap: () => _setPresetDate(60), isSelected: _isPresetSelected(60), ), _DatePresetCard( value: '90', - unit: 'cutoff_day'.t(context: context, args: {'count': '90'}), + unit: context.t.cutoff_day(count: 90), onTap: () => _setPresetDate(90), isSelected: _isPresetSelected(90), ), _DatePresetCard( value: '1', - unit: 'cutoff_year'.t(context: context, args: {'count': '1'}), + unit: context.t.cutoff_year(count: 1), onTap: () => _setPresetDate(365), isSelected: _isPresetSelected(365), ), _DatePresetCard( value: '2', - unit: 'cutoff_year'.t(context: context, args: {'count': '2'}), + unit: context.t.cutoff_year(count: 2), onTap: () => _setPresetDate(730), isSelected: _isPresetSelected(730), ), _DatePresetCard( value: '3', - unit: 'cutoff_year'.t(context: context, args: {'count': '3'}), + unit: context.t.cutoff_year(count: 3), onTap: () => _setPresetDate(1095), isSelected: _isPresetSelected(1095), ), @@ -483,14 +487,14 @@ class _FreeUpSpaceSettingsState extends ConsumerState { OutlinedButton.icon( onPressed: _selectDate, icon: const Icon(Icons.calendar_today), - label: Text('custom_date'.t(context: context)), + label: Text(context.t.custom_date), style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), ), const SizedBox(height: 16), ElevatedButton.icon( onPressed: hasDate ? _goToScanStep : null, icon: const Icon(Icons.arrow_forward), - label: Text('continue'.t(context: context)), + label: Text(context.t.continue$), style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), ), ], @@ -503,7 +507,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { Step( stepStyle: styleForState(step2State), title: Text( - 'scan'.t(context: context), + context.t.scan, style: context.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: step2State == StepState.complete @@ -516,17 +520,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState { subtitle: _hasScanned ? Text( state.totalBytes > 0 - ? 'cleanup_found_assets_with_size'.t( - context: context, - args: { - 'count': state.assetsToDelete.length.toString(), - 'size': formatBytes(state.totalBytes), - }, + ? context.t.cleanup_found_assets_with_size( + count: state.assetsToDelete.length, + size: formatBytes(state.totalBytes), ) - : 'cleanup_found_assets'.t( - context: context, - args: {'count': state.assetsToDelete.length.toString()}, - ), + : context.t.cleanup_found_assets(count: state.assetsToDelete.length), style: context.textTheme.bodyMedium?.copyWith( color: state.assetsToDelete.isNotEmpty ? context.colorScheme.primary @@ -537,7 +535,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { : null, content: Column( children: [ - Text('cleanup_step3_description'.t(context: context), style: subtitleStyle), + Text(context.t.cleanup_step3_description, style: subtitleStyle), if (CurrentPlatform.isIOS) ...[ const SizedBox(height: 12), Container( @@ -552,7 +550,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { const SizedBox(width: 12), Expanded( child: Text( - 'cleanup_icloud_shared_albums_excluded'.t(context: context), + context.t.cleanup_icloud_shared_albums_excluded, style: context.textTheme.labelLarge, ), ), @@ -573,7 +571,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { : ElevatedButton.icon( onPressed: state.isScanning ? null : _scanAssets, icon: const Icon(Icons.search), - label: Text(_hasScanned ? 'rescan'.t(context: context) : 'scan'.t(context: context)), + label: Text(_hasScanned ? context.t.rescan : context.t.scan), style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), ), if (_hasScanned && state.assetsToDelete.isEmpty) ...[ @@ -589,10 +587,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { const Icon(Icons.info, color: Colors.orange), const SizedBox(width: 12), Expanded( - child: Text( - 'cleanup_no_assets_found'.t(context: context), - style: context.textTheme.bodyMedium, - ), + child: Text(context.t.cleanup_no_assets_found, style: context.textTheme.bodyMedium), ), ], ), @@ -608,7 +603,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { Step( stepStyle: styleForState(step3State, isDestructive: true), title: Text( - 'move_to_device_trash'.t(context: context), + context.t.move_to_device_trash, style: context.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: step3State == StepState.disabled @@ -631,12 +626,9 @@ class _FreeUpSpaceSettingsState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'cleanup_step4_summary'.t( - context: context, - args: { - 'count': state.assetsToDelete.length.toString(), - 'date': DateFormat.yMMMd().format(state.selectedDate!), - }, + context.t.cleanup_step4_summary( + count: state.assetsToDelete.length, + date: DateFormat.yMMMd().format(state.selectedDate!), ), style: context.textTheme.labelLarge?.copyWith(fontSize: 15), ), @@ -648,7 +640,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { OutlinedButton.icon( onPressed: () => _showAssetsPreview(state.assetsToDelete), icon: const Icon(Icons.preview), - label: Text('preview'.t(context: context)), + label: Text(context.t.preview), style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), ), const SizedBox(height: 12), @@ -661,11 +653,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : const Icon(Icons.delete_forever), - label: Text( - state.isDeleting - ? 'cleanup_deleting'.t(context: context) - : 'move_to_device_trash'.t(context: context), - ), + label: Text(state.isDeleting ? context.t.cleanup_deleting : context.t.move_to_device_trash), style: ElevatedButton.styleFrom( backgroundColor: context.colorScheme.error, foregroundColor: context.colorScheme.onError, @@ -697,26 +685,20 @@ class _DeleteConfirmationDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: Text('cleanup_confirm_prompt_title'.t(context: context)), + title: Text(context.t.cleanup_confirm_prompt_title), content: Text( - 'cleanup_confirm_description'.t( - context: context, - args: {'count': assetCount.toString(), 'date': DateFormat.yMMMd().format(cutoffDate)}, - ), + context.t.cleanup_confirm_description(count: assetCount, date: DateFormat.yMMMd().format(cutoffDate)), style: context.textTheme.labelLarge?.copyWith(fontSize: 15), ), actions: [ - TextButton( - onPressed: () => ContextHelper(context).pop(false), - child: Text('cancel'.t(context: context)), - ), + TextButton(onPressed: () => ContextHelper(context).pop(false), child: Text(context.t.cancel)), ElevatedButton( onPressed: () => ContextHelper(context).pop(true), style: ElevatedButton.styleFrom( backgroundColor: context.colorScheme.error, foregroundColor: context.colorScheme.onError, ), - child: Text('confirm'.t(context: context)), + child: Text(context.t.confirm), ), ], ); @@ -732,29 +714,24 @@ class _DeleteSuccessDialog extends StatelessWidget { Widget build(BuildContext context) { return AlertDialog( icon: Icon(Icons.check_circle, color: context.colorScheme.primary, size: 48), - title: Text('success'.t(context: context)), + title: Text(context.t.success), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text( - 'cleanup_deleted_assets'.t(context: context, args: {'count': deletedCount.toString()}), + context.t.cleanup_deleted_assets(count: deletedCount), style: context.textTheme.labelLarge?.copyWith(fontSize: 16), textAlign: TextAlign.center, ), const SizedBox(height: 16), Text( - 'cleanup_trash_hint'.t(context: context), + context.t.cleanup_trash_hint, style: context.textTheme.labelLarge?.copyWith(fontSize: 16, color: context.primaryColor), textAlign: TextAlign.center, ), ], ), - actions: [ - ElevatedButton( - onPressed: () => ContextHelper(context).pop(), - child: Text('done'.t(context: context)), - ), - ], + actions: [ElevatedButton(onPressed: () => ContextHelper(context).pop(), child: Text(context.t.done))], ); } } @@ -820,7 +797,7 @@ class _KeepAlbumsSection extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'keep_albums'.t(context: context), + context.t.keep_albums, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), ), @@ -830,13 +807,13 @@ class _KeepAlbumsSection extends ConsumerWidget { child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(strokeWidth: 2)), ), error: (error, stack) => Text( - 'error_loading_albums'.t(context: context), + context.t.error_loading_albums, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), ), data: (albums) { if (albums.isEmpty) { return Text( - 'no_albums_found'.t(context: context), + context.t.no_albums_found, style: context.textTheme.bodyMedium?.copyWith( color: context.colorScheme.onSurface.withValues(alpha: 0.6), ), @@ -866,7 +843,7 @@ class _KeepAlbumsSection extends ConsumerWidget { if (albumIds.isNotEmpty) ...[ const SizedBox(height: 8), Text( - 'keep_albums_count'.t(context: context, args: {'count': albumIds.length.toString()}), + context.t.keep_albums_count(count: albumIds.length), style: context.textTheme.bodySmall?.copyWith( color: context.colorScheme.primary, fontWeight: FontWeight.w500, diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart index b12d1476f5..61f2088979 100644 --- a/mobile/lib/widgets/settings/language_settings.dart +++ b/mobile/lib/widgets/settings/language_settings.dart @@ -3,14 +3,13 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; -class LanguageSettings extends HookConsumerWidget { +class LanguageSettings extends HookWidget { const LanguageSettings({super.key}); Future _applyLanguageChange( @@ -33,7 +32,7 @@ class LanguageSettings extends HookConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final localeEntries = useMemoized(() => locales.entries.toList(), const []); final currentLocale = context.locale; final filteredLocaleEntries = useState>>(localeEntries); @@ -153,7 +152,7 @@ class _LanguageSearchBar extends StatelessWidget { child: SearchField( autofocus: false, contentPadding: const EdgeInsets.all(12), - hintText: 'language_search_hint'.t(context: context), + hintText: context.t.language_search_hint, prefixIcon: const Icon(Icons.search_rounded), suffixIcon: controller.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear_rounded), onPressed: onClear) @@ -180,12 +179,12 @@ class _LanguageNotFound extends StatelessWidget { Icon(Icons.search_off_rounded, size: 64, color: context.colorScheme.onSurface.withValues(alpha: 0.4)), const SizedBox(height: 8), Text( - 'language_no_results_title'.t(context: context), + context.t.language_no_results_title, style: context.textTheme.titleMedium?.copyWith(color: context.colorScheme.onSurface), ), const SizedBox(height: 4), Text( - 'language_no_results_subtitle'.t(context: context), + context.t.language_no_results_subtitle, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.8)), ), ], @@ -215,7 +214,7 @@ class _LanguageApplyButton extends StatelessWidget { child: isLoading ? const SizedBox.square(dimension: 24, child: CircularProgressIndicator(strokeWidth: 2)) : Text( - 'setting_languages_apply'.t(context: context), + context.t.setting_languages_apply, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16.0), ), ), diff --git a/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart b/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart index 162f1252a8..349b32287b 100644 --- a/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart +++ b/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; @@ -78,11 +78,11 @@ class EndpointInputState extends ConsumerState { try { if (url == null || url.isEmpty || !Uri.parse(url).isAbsolute) { isInputValid = false; - return 'validate_endpoint_error'.tr(); + return context.t.validate_endpoint_error; } } catch (_) { isInputValid = false; - return 'validate_endpoint_error'.tr(); + return context.t.validate_endpoint_error; } isInputValid = true; diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart index 68278e0e1d..63b094b440 100644 --- a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart @@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; @@ -36,7 +36,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { entries.value[index] = entries.value[index].copyWith(url: url, status: status); await saveEndpointList(); - if (status == AuxCheckStatus.valid) { + if (status == AuxCheckStatus.valid && context.mounted) { await ref.read(apiServiceProvider).updateHeaders(); } } @@ -103,7 +103,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 24), - child: Text("external_network_sheet_info".t(context: context), style: context.textTheme.bodyMedium), + child: Text(context.t.external_network_sheet_info, style: context.textTheme.bodyMedium), ), const SizedBox(height: 4), Divider(color: context.colorScheme.surfaceContainerHighest), @@ -135,7 +135,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { height: 48, child: OutlinedButton.icon( icon: const Icon(Icons.add), - label: Text('add_endpoint'.t(context: context)), + label: Text(context.t.add_endpoint), onPressed: enabled ? () { entries.value = [ diff --git a/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart index 8b787bcaea..3c4fa73543 100644 --- a/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart @@ -1,11 +1,10 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/network.provider.dart'; import 'package:immich_ui/immich_ui.dart'; @@ -34,9 +33,12 @@ class LocalNetworkPreference extends HookConsumerWidget { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('cancel'.tr().toUpperCase(), style: const TextStyle(color: Colors.red)), + child: Text(context.t.cancel.toUpperCase(), style: const TextStyle(color: Colors.red)), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text(context.t.save.toUpperCase()), ), - TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('save'.tr().toUpperCase())), ], ), ); @@ -73,7 +75,12 @@ class LocalNetworkPreference extends HookConsumerWidget { } Future handleEditWifiName() async { - final wifiName = await _showEditDialog(context, "wifi_name".tr(), "your_wifi_name".tr(), wifiNameText.value); + final wifiName = await _showEditDialog( + context, + context.t.wifi_name, + context.t.your_wifi_name, + wifiNameText.value, + ); if (wifiName != null) { await saveWifiName(wifiName); @@ -83,7 +90,7 @@ class LocalNetworkPreference extends HookConsumerWidget { Future handleEditServerEndpoint() async { final localEndpoint = await _showEditDialog( context, - "server_endpoint".tr(), + context.t.server_endpoint, "http://local-ip:2283", localEndpointText.value, isUrlField: true, @@ -104,7 +111,7 @@ class LocalNetworkPreference extends HookConsumerWidget { context.showSnackBar( SnackBar( content: Text( - "get_wifiname_error".tr(), + context.t.get_wifiname_error, style: context.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, color: context.colorScheme.onSecondary, @@ -149,7 +156,7 @@ class LocalNetworkPreference extends HookConsumerWidget { children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 24), - child: Text("local_network_sheet_info".tr(), style: context.textTheme.bodyMedium), + child: Text(context.t.local_network_sheet_info, style: context.textTheme.bodyMedium), ), const SizedBox(height: 4), Divider(color: context.colorScheme.surfaceContainerHighest), @@ -157,9 +164,9 @@ class LocalNetworkPreference extends HookConsumerWidget { enabled: enabled, contentPadding: const EdgeInsets.only(left: 24, right: 8), leading: const Icon(Icons.wifi_rounded), - title: Text("wifi_name".tr()), + title: Text(context.t.wifi_name), subtitle: wifiNameText.value.isEmpty - ? Text("enter_wifi_name".tr()) + ? Text(context.t.enter_wifi_name) : Text( wifiNameText.value, style: context.textTheme.labelLarge?.copyWith( @@ -177,7 +184,7 @@ class LocalNetworkPreference extends HookConsumerWidget { enabled: enabled, contentPadding: const EdgeInsets.only(left: 24, right: 8), leading: const Icon(Icons.lan_rounded), - title: Text("server_endpoint".t(context: context)), + title: Text(context.t.server_endpoint), subtitle: localEndpointText.value.isEmpty ? const Text("http://local-ip:2283") : Text( @@ -199,7 +206,7 @@ class LocalNetworkPreference extends HookConsumerWidget { height: 48, child: OutlinedButton.icon( icon: const Icon(Icons.wifi_find_rounded), - label: Text('use_current_connection'.t(context: context)), + label: Text(context.t.use_current_connection), onPressed: enabled ? autofillCurrentNetwork : null, ), ), diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 513648f030..80b0b78fc2 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -1,11 +1,10 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/network.provider.dart'; @@ -21,8 +20,8 @@ class NetworkingSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final currentEndpoint = getServerUrl(); - final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching); - useValueChanged(featureEnabled.value, (_, __) { + final featureEnabled = useState(ref.watch(appConfigProvider).network.autoEndpointSwitching); + useValueChanged(featureEnabled.value, (_, _) { unawaited(ref.read(settingsProvider).write(.networkAutoEndpointSwitching, featureEnabled.value)); }); @@ -39,8 +38,8 @@ class NetworkingSettings extends HookConsumerWidget { context: context, builder: (context) { return AlertDialog( - title: Text("location_permission".tr()), - content: Text("location_permission_content".tr()), + title: Text(context.t.location_permission), + content: Text(context.t.location_permission_content), actions: [ TextButton( onPressed: () async { @@ -52,7 +51,7 @@ class NetworkingSettings extends HookConsumerWidget { Navigator.pop(context, isGrant); }, - child: Text("grant_permission".tr()), + child: Text(context.t.grant_permission), ), ], ); @@ -69,8 +68,8 @@ class NetworkingSettings extends HookConsumerWidget { context: context, builder: (context) { return AlertDialog( - title: Text("background_location_permission".tr()), - content: Text("background_location_permission_content".tr()), + title: Text(context.t.background_location_permission), + content: Text(context.t.background_location_permission_content), actions: [ TextButton( onPressed: () async { @@ -82,7 +81,7 @@ class NetworkingSettings extends HookConsumerWidget { Navigator.pop(context, isGrant); }, - child: Text("grant_permission".tr()), + child: Text(context.t.grant_permission), ), ], ); @@ -107,7 +106,7 @@ class NetworkingSettings extends HookConsumerWidget { children: [ const SizedBox(height: 8), SettingGroupTitle( - title: "current_server_address".t(context: context), + title: context.t.current_server_address, icon: (currentEndpoint?.startsWith('https') ?? false) ? Icons.https_outlined : Icons.http_outlined, ), Padding( @@ -136,20 +135,14 @@ class NetworkingSettings extends HookConsumerWidget { SettingsSwitchListTile( enabled: true, valueNotifier: featureEnabled, - title: "automatic_endpoint_switching_title".tr(), - subtitle: "automatic_endpoint_switching_subtitle".tr(), + title: context.t.automatic_endpoint_switching_title, + subtitle: context.t.automatic_endpoint_switching_subtitle, ), const SizedBox(height: 8), - SettingGroupTitle( - title: "local_network".t(context: context), - icon: Icons.home_outlined, - ), + SettingGroupTitle(title: context.t.local_network, icon: Icons.home_outlined), LocalNetworkPreference(enabled: featureEnabled.value), const SizedBox(height: 16), - SettingGroupTitle( - title: "external_network".t(context: context), - icon: Icons.dns_outlined, - ), + SettingGroupTitle(title: context.t.external_network, icon: Icons.dns_outlined), ExternalNetworkPreference(enabled: featureEnabled.value), ], ); diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart index 8c858a231b..c3d175999d 100644 --- a/mobile/lib/widgets/settings/notification_setting.dart +++ b/mobile/lib/widgets/settings/notification_setting.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/permission.provider.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; @@ -29,10 +29,10 @@ class NotificationSetting extends HookConsumerWidget { showDialog( context: context, builder: (ctx) => AlertDialog( - content: const Text('notification_permission_dialog_content').tr(), + content: Text(ctx.t.notification_permission_dialog_content), actions: [ - TextButton(child: const Text('cancel').tr(), onPressed: () => ctx.pop()), - TextButton(onPressed: () => openAppNotificationSettings(ctx), child: const Text('settings').tr()), + TextButton(child: Text(ctx.t.cancel), onPressed: () => ctx.pop()), + TextButton(onPressed: () => openAppNotificationSettings(ctx), child: Text(ctx.t.settings)), ], ), ), @@ -43,11 +43,11 @@ class NotificationSetting extends HookConsumerWidget { if (!hasPermission) SettingsButtonListTile( icon: Icons.notifications_outlined, - title: 'notification_permission_list_tile_title'.tr(), - subtileText: 'notification_permission_list_tile_content'.tr(), - buttonText: 'notification_permission_list_tile_enable_button'.tr(), + title: context.t.notification_permission_list_tile_title, + subtileText: context.t.notification_permission_list_tile_content, + buttonText: context.t.notification_permission_list_tile_enable_button, onButtonTap: () => - ref.watch(notificationPermissionProvider.notifier).requestNotificationPermission().then((permission) { + ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((permission) { if (permission == PermissionStatus.permanentlyDenied) { showPermissionsDialog(); } @@ -56,9 +56,9 @@ class NotificationSetting extends HookConsumerWidget { else SettingsButtonListTile( icon: Icons.notifications_active_outlined, - title: 'notification_enabled_list_tile_title'.tr(), - subtileText: 'notification_enabled_list_tile_content'.tr(), - buttonText: 'notification_enabled_list_tile_open_button'.tr(), + title: context.t.notification_enabled_list_tile_title, + subtileText: context.t.notification_enabled_list_tile_content, + buttonText: context.t.notification_enabled_list_tile_open_button, onButtonTap: () => openAppSettings(), ), ]; diff --git a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart index bfdb0f065c..9aaae683ab 100644 --- a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart @@ -1,17 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -class HapticSetting extends HookConsumerWidget { +class HapticSetting extends HookWidget { const HapticSetting({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final hapticFeedbackSetting = useAppSettingsState(AppSettingsEnum.enableHapticFeedback); final isHapticFeedbackEnabled = useValueNotifier(hapticFeedbackSetting.value); @@ -22,13 +21,10 @@ class HapticSetting extends HookConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingGroupTitle( - title: "haptic_feedback_title".t(context: context), - icon: Icons.vibration_outlined, - ), + SettingGroupTitle(title: context.t.haptic_feedback_title, icon: Icons.vibration_outlined), SettingsSwitchListTile( valueNotifier: isHapticFeedbackEnabled, - title: 'enabled'.t(context: context), + title: context.t.enabled, onChanged: onHapticFeedbackChange, ), ], diff --git a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart index b1564b4145..37235a182a 100644 --- a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/theme/color_scheme.dart'; @@ -16,7 +16,7 @@ class PrimaryColorSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final themeProvider = ref.read(immichThemeProvider); + final themeProvider = ref.watch(immichThemeProvider); final themeConfig = ref.watch(appConfigProvider.select((config) => config.theme)); const tileSize = 55.0; @@ -97,7 +97,7 @@ class PrimaryColorSetting extends HookConsumerWidget { children: [ Align( alignment: Alignment.center, - child: Text("theme_setting_primary_color_title".tr(), style: context.textTheme.titleLarge), + child: Text(context.t.theme_setting_primary_color_title, style: context.textTheme.titleLarge), ), if (DynamicTheme.isAvailable) Container( @@ -110,7 +110,7 @@ class PrimaryColorSetting extends HookConsumerWidget { tileColor: context.colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))), title: Text( - 'theme_setting_system_primary_color_title'.tr(), + context.t.theme_setting_system_primary_color_title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500, height: 1.5), ), value: themeConfig.dynamicTheme, @@ -157,11 +157,11 @@ class PrimaryColorSetting extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "theme_setting_primary_color_title".tr(), + context.t.theme_setting_primary_color_title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), ), Text( - "theme_setting_primary_color_subtitle".tr(), + context.t.theme_setting_primary_color_subtitle, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), ], diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart index ec84d7be01..cf1a22c45f 100644 --- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; @@ -15,7 +15,7 @@ class ThemeSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentTheme = useState(ref.read(appConfigProvider.select((config) => config.theme.mode))); + final currentTheme = useState(ref.watch(appConfigProvider.select((config) => config.theme.mode))); final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark); final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system); final colorfulInterface = useValueNotifier( @@ -52,26 +52,23 @@ class ThemeSetting extends HookConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingGroupTitle( - title: "theme".t(context: context), - icon: Icons.color_lens_outlined, - ), + SettingGroupTitle(title: context.t.theme, icon: Icons.color_lens_outlined), SettingsSwitchListTile( valueNotifier: isSystemTheme, - title: 'theme_setting_system_theme_switch'.t(context: context), + title: context.t.theme_setting_system_theme_switch, onChanged: onSystemThemeChange, ), if (currentTheme.value != ThemeMode.system) SettingsSwitchListTile( valueNotifier: isDarkTheme, - title: 'map_settings_dark_mode'.t(context: context), + title: context.t.map_settings_dark_mode, onChanged: onThemeChange, ), const PrimaryColorSetting(), SettingsSwitchListTile( valueNotifier: colorfulInterface, - title: "theme_setting_colorful_interface_title".t(context: context), - subtitle: 'theme_setting_colorful_interface_subtitle'.t(context: context), + title: context.t.theme_setting_colorful_interface_title, + subtitle: context.t.theme_setting_colorful_interface_subtitle, onChanged: onSurfaceColorSettingChange, ), ], diff --git a/mobile/lib/widgets/settings/settings_button_list_tile.dart b/mobile/lib/widgets/settings/settings_button_list_tile.dart index 0e8d75b22f..1e06c0b2b4 100644 --- a/mobile/lib/widgets/settings/settings_button_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_button_list_tile.dart @@ -41,7 +41,7 @@ class SettingsButtonListTile extends StatelessWidget { subtileText!, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), - if (subtitle != null) subtitle!, + ?subtitle, const SizedBox(height: 6), child ?? ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), ], diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index 77ad7ee179..f37aa04b33 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:logging/logging.dart'; @@ -44,12 +44,15 @@ class _SslClientCertSettingsState extends State { contentPadding: const EdgeInsets.symmetric(horizontal: 20), horizontalTitleGap: 20, isThreeLine: true, - title: Text("client_cert_title".tr(), style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), + title: Text( + context.t.client_cert_title, + style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "client_cert_subtitle".tr(), + context.t.client_cert_subtitle, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), const SizedBox(height: 6), @@ -58,8 +61,8 @@ class _SslClientCertSettingsState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ - ElevatedButton(onPressed: importCert, child: Text("client_cert_import".tr())), - ElevatedButton(onPressed: !isCertExist ? null : removeCert, child: Text("remove".tr())), + ElevatedButton(onPressed: importCert, child: Text(context.t.client_cert_import)), + ElevatedButton(onPressed: !isCertExist ? null : removeCert, child: Text(context.t.remove)), ], ), ], @@ -79,20 +82,20 @@ class _SslClientCertSettingsState extends State { Future importCert() async { try { final styling = ClientCertPrompt( - title: "client_cert_password_title".tr(), - message: "client_cert_password_message".tr(), - cancel: "cancel".tr(), - confirm: "confirm".tr(), + title: context.t.client_cert_password_title, + message: context.t.client_cert_password_message, + cancel: context.t.cancel, + confirm: context.t.confirm, ); await networkApi.selectCertificate(styling); setState(() => isCertExist = true); - showMessage("client_cert_import_success_msg".tr()); + showMessage(StaticTranslations.instance.client_cert_import_success_msg); } catch (e) { if (_isCancellation(e)) { return; } _log.severe("Error importing client cert", e); - showMessage("client_cert_invalid_msg".tr()); + showMessage(StaticTranslations.instance.client_cert_invalid_msg); } } @@ -100,13 +103,13 @@ class _SslClientCertSettingsState extends State { try { await networkApi.removeCertificate(); setState(() => isCertExist = false); - showMessage("client_cert_remove_msg".tr()); + showMessage(StaticTranslations.instance.client_cert_remove_msg); } catch (e) { if (_isCancellation(e)) { return; } _log.severe("Error removing client cert", e); - showMessage("client_cert_invalid_msg".tr()); + showMessage(StaticTranslations.instance.client_cert_invalid_msg); } } diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index d419d6ead0..2471c19cea 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -173,13 +173,13 @@ class SharedLinkItem extends ConsumerWidget { final confirmed = await showDialog( context: context, builder: (BuildContext context) => ConfirmDialog( - title: "delete_shared_link_dialog_title", - content: "confirm_delete_shared_link", + title: context.t.delete_shared_link_dialog_title, + content: context.t.confirm_delete_shared_link, onOk: () {}, ), ); - if (confirmed == true) { + if (confirmed == true && context.mounted) { await ref.read(sharedLinksStateProvider.notifier).deleteLink(sharedLink.id); return true; } diff --git a/mobile/mise.lock b/mobile/mise.lock index 8f2f9572fb..cd3ac25848 100644 --- a/mobile/mise.lock +++ b/mobile/mise.lock @@ -1,30 +1,30 @@ # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html [[tools."aqua:flutter/flutter"]] -version = "3.44.8" +version = "3.44.9" backend = "aqua:flutter/flutter" [tools."aqua:flutter/flutter"."platforms.linux-arm64"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.9-stable.tar.xz" [tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.9-stable.tar.xz" [tools."aqua:flutter/flutter"."platforms.linux-x64"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.9-stable.tar.xz" [tools."aqua:flutter/flutter"."platforms.linux-x64-musl"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.9-stable.tar.xz" [tools."aqua:flutter/flutter"."platforms.macos-arm64"] -checksum = "blake3:79c780876c64f5a66c015845f544bdb20275f7f3bf819e6c831f23df3e96b8db" -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.8-stable.zip" +checksum = "blake3:e5c14d6b5e917a8578327d2564ab78ece83cd685c74c29572ddaf7655f42205d" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.9-stable.zip" [tools."aqua:flutter/flutter"."platforms.macos-x64"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.8-stable.zip" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.9-stable.zip" [tools."aqua:flutter/flutter"."platforms.windows-x64"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.8-stable.zip" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.9-stable.zip" [[tools."github:CQLabs/homebrew-dcm"]] version = "1.37.0" diff --git a/mobile/mise.toml b/mobile/mise.toml index ee312178a4..d8ddc8ab21 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -1,5 +1,5 @@ [tools] -"aqua:flutter/flutter" = "3.44.8" +"aqua:flutter/flutter" = "3.44.9" java = "21.0.2" [tools."github:CQLabs/homebrew-dcm"] @@ -29,7 +29,7 @@ sources = [ ] outputs = { auto = true } run = [ - "dart run build_runner build", + "dart run build_runner build", "dart format lib/routing/router.gr.dart", ] depends = ["//:open-api-dart"] @@ -58,13 +58,7 @@ depends = ["//:open-api-dart"] [tasks."codegen:translation"] alias = "translation" description = "Generate translations from i18n JSONs" -run = [ - { task = "//:i18n:format-fix" }, - { tasks = [ - "i18n:loader", - "i18n:keys", - ] }, -] +depends = ["//:i18n:format-fix", "i18n:loader", "i18n:keys"] [tasks."codegen:app-icon"] description = "Generate app icons" @@ -105,7 +99,11 @@ run = "dart run drift_dev make-migrations" alias = "install" description = "Install flutter dependencies" depends = ["//:open-api-dart"] -run = "flutter pub get" +run = [ + # This receives passed `--enforce-lockfile` args + "flutter pub get", + { task = "install:ios" }, +] [tasks."install:ci"] description = "Install flutter dependencies in CI" @@ -113,6 +111,18 @@ run = [ { task = "install", args = ["--enforce-lockfile"] }, ] +[tasks.checkout] +description = "Bring a checkout up to date after switching branches" +run = [ + { task = "install" }, + { task = "checkout:ios" }, + { task = "codegen" }, +] + +[tasks."checkout:ios"] +description = "Bring the iOS project up to date after switching branches" +run = "bash ./scripts/checkout_ios.sh" + [tasks.start] alias = "start" description = "Start flutter app" @@ -127,6 +137,7 @@ run = [ "dart format lib/generated/codegen_loader.g.dart", ] depends = ["//:open-api-dart"] +wait_for = ["//:i18n:format-fix"] [tasks."i18n:keys"] description = "Generate i18n keys" @@ -135,6 +146,20 @@ run = [ "dart run bin/generate_keys.dart", "dart format lib/generated/translations.g.dart", ] +wait_for = ["//:i18n:format-fix"] + +[tasks."install:ios"] +description = "Install CocoaPods dependencies" +hide = true +sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugins-dependencies"] +outputs = ["ios/Pods/Manifest.lock"] +run = ''' +echo "Running pod install" +if [ "$(uname)" != "Darwin" ]; then + exit 0 +fi +cd ios && pod install +''' [tasks."analyze:dart"] description = "Run Dart analysis" diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 8ea88135e5..85875717a3 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,6 +1,7 @@ export 'src/color_override.dart'; export 'src/components/close_button.dart'; export 'src/components/column_button.dart'; +export 'src/components/email_input.dart'; export 'src/components/form.dart'; export 'src/components/formatted_text.dart'; export 'src/components/icon_button.dart'; diff --git a/mobile/packages/ui/lib/src/components/email_input.dart b/mobile/packages/ui/lib/src/components/email_input.dart new file mode 100644 index 0000000000..27d79e9f0e --- /dev/null +++ b/mobile/packages/ui/lib/src/components/email_input.dart @@ -0,0 +1,26 @@ +import 'package:flutter/services.dart'; +import 'package:immich_ui/src/components/text_input.dart'; + +/// An ImmichTextInput customized for receiving email addresses +class ImmichEmailInput extends ImmichTextInput { + const ImmichEmailInput({ + super.key, + super.controller, + super.focusNode, + super.label, + super.hintText, + super.validator, + super.onSubmit, + super.keyboardAction, + super.suffixIcon, + super.enabled, + super.autofocus, + super.autovalidateMode, + }) : super( + keyboardType: .emailAddress, + autofillHints: const [AutofillHints.email], + autocorrect: false, + smartDashesType: .disabled, + smartQuotesType: .disabled, + ); +} diff --git a/mobile/packages/ui/lib/src/components/password_input.dart b/mobile/packages/ui/lib/src/components/password_input.dart index d08b3de99d..aa66d43b4d 100644 --- a/mobile/packages/ui/lib/src/components/password_input.dart +++ b/mobile/packages/ui/lib/src/components/password_input.dart @@ -47,6 +47,9 @@ class _ImmichPasswordInputState extends State { onSubmit: widget.onSubmit, keyboardAction: widget.keyboardAction, obscureText: !_visible, + autocorrect: false, + smartDashesType: .disabled, + smartQuotesType: .disabled, suffixIcon: IconButton( onPressed: _toggleVisibility, icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded), diff --git a/mobile/packages/ui/lib/src/previews/email_input.dart b/mobile/packages/ui/lib/src/previews/email_input.dart new file mode 100644 index 0000000000..b9d1d64ea0 --- /dev/null +++ b/mobile/packages/ui/lib/src/previews/email_input.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/components/email_input.dart'; +import 'package:immich_ui/src/previews.dart'; + +@ImmichPreview(group: 'EmailInput', name: 'Basic') +Widget previewEmailInput() => const _PreviewEmailInput(); + +class _PreviewEmailInput extends StatefulWidget { + const _PreviewEmailInput(); + + @override + State<_PreviewEmailInput> createState() => _PreviewEmailInputState(); +} + +class _PreviewEmailInputState extends State<_PreviewEmailInput> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ImmichEmailInput(label: 'Email', hintText: 'user@immich.app', controller: _controller); + } +} diff --git a/mobile/packages/ui/test/smart_punctuation_test.dart b/mobile/packages/ui/test/smart_punctuation_test.dart new file mode 100644 index 0000000000..93f1f7a92d --- /dev/null +++ b/mobile/packages/ui/test/smart_punctuation_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_ui/src/components/email_input.dart'; +import 'package:immich_ui/src/components/password_input.dart'; + +import 'test_utils.dart'; + +void main() { + EditableText editable(WidgetTester tester) => tester.widget(find.byType(EditableText)); + + testWidgets('ImmichEmailInput disables smart punctuation', (tester) async { + await tester.pumpTestWidget(const ImmichEmailInput()); + + expect(editable(tester).smartDashesType, SmartDashesType.disabled); + expect(editable(tester).smartQuotesType, SmartQuotesType.disabled); + }); + + testWidgets('ImmichPasswordInput disables smart punctuation', (tester) async { + await tester.pumpTestWidget(const ImmichPasswordInput()); + + expect(editable(tester).obscureText, isTrue); + expect(editable(tester).smartDashesType, SmartDashesType.disabled); + expect(editable(tester).smartQuotesType, SmartQuotesType.disabled); + + await tester.tap(find.byIcon(Icons.visibility_rounded)); + await tester.pump(); + + expect(editable(tester).obscureText, isFalse); + expect(editable(tester).smartDashesType, SmartDashesType.disabled); + expect(editable(tester).smartQuotesType, SmartQuotesType.disabled); + }); +} diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart index 7f0135acb8..277b7016fd 100644 --- a/mobile/pigeon/remote_image_api.dart +++ b/mobile/pigeon/remote_image_api.dart @@ -13,8 +13,15 @@ import 'package:pigeon/pigeon.dart'; ) @HostApi() abstract class RemoteImageApi { + /// Width and height are the physical decode size, or null for the source size. @async - Map? requestImage(String url, {required int requestId, required bool preferEncoded}); + Map? requestImage( + String url, { + required int requestId, + required bool preferEncoded, + int? width, + int? height, + }); void cancelRequest(int requestId); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index e0198f2683..34676069dd 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: "direct main" description: name: background_downloader - sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3 + sha256: d27fcaf94f738032f1e6db098230799bf31ae7bf184f173fc9a752fcdae75c0e url: "https://pub.dev" source: hosted - version: "9.5.5" + version: "9.5.6" bonsoir: dependency: "direct overridden" description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.7" build_config: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "2.15.1" built_collection: dependency: transitive description: @@ -253,10 +253,10 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057" + sha256: "25cebb79dfe304022550e0c3c893ae051ded07183d2607f7b88473cb3ade33cb" url: "https://pub.dev" source: hosted - version: "7.1.1" + version: "7.3.0" connectivity_plus_platform_interface: dependency: transitive description: @@ -512,10 +512,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_local_notifications: dependency: "direct main" description: @@ -634,10 +634,10 @@ packages: dependency: "direct main" description: name: flutter_udid - sha256: fc599671cbe8b328e509c961ec121880406ed994dde659cc9ece9c7503cd31c7 + sha256: f23555e9d34d5a7dc70fdfa3ab8915b401786879d278568b3c0e801af9a14d2e url: "https://pub.dev" source: hosted - version: "4.1.2" + version: "4.1.6" flutter_web_auth_2: dependency: "direct main" description: @@ -667,6 +667,22 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.14" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + url: "https://pub.dev" + source: hosted + version: "3.2.5" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" frontend_server_client: dependency: transitive description: @@ -852,10 +868,10 @@ packages: dependency: "direct main" description: name: image_picker - sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.3" image_picker_android: dependency: transitive description: @@ -1000,10 +1016,10 @@ packages: dependency: transitive description: name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "6.1.0" local_auth: dependency: "direct main" description: @@ -1234,10 +1250,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -1640,18 +1656,18 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189" + sha256: "61c7930bebf32c552ac5808c91bf33802e66711c9216090d3b5579c9f862e80c" url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.4.0" sqlite3_connection_pool: dependency: "direct main" description: name: sqlite3_connection_pool - sha256: "9d2b3b398b03c96743fd071521fc665be73c33c9cd5c56d87196baff8d8b4398" + sha256: ad51972d37bdf82929888e4c85e722bd9a0b5ff8ad389f89b1b00d19603c0d2d url: "https://pub.dev" source: hosted - version: "0.2.6" + version: "0.2.7" sqlite3_web: dependency: transitive description: @@ -1848,10 +1864,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.6.0" vector_graphics: dependency: transitive description: @@ -2014,4 +2030,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: "3.44.8" + flutter: "3.44.9" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a55bcf515b..5a83ad7556 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,15 +6,15 @@ version: 3.1.0+3057 environment: sdk: '>=3.12.0 <4.0.0' - flutter: 3.44.8 + flutter: 3.44.9 dependencies: async: ^2.13.1 auto_route: ^11.1.0 - background_downloader: ^9.5.4 + background_downloader: ^9.5.6 cast: ^2.1.0 collection: ^1.19.1 - connectivity_plus: ^7.0.0 + connectivity_plus: ^7.3.0 crop_image: ^1.0.17 crypto: ^3.0.7 device_info_plus: ^12.4.0 @@ -31,14 +31,14 @@ dependencies: flutter_local_notifications: ^17.2.4 flutter_secure_storage: ^9.2.4 flutter_svg: ^2.2.4 - flutter_udid: ^4.1.2 + flutter_udid: ^4.1.6 flutter_web_auth_2: ^5.0.2 fluttertoast: ^8.2.14 geolocator: ^14.0.2 home_widget: ^0.9.3 hooks_riverpod: ^2.6.1 http: ^1.6.0 - image_picker: ^1.2.1 + image_picker: ^1.2.3 immich_ui: path: './packages/ui' intl: ^0.20.2 @@ -55,7 +55,7 @@ dependencies: path: generated/openapi package_info_plus: ^8.3.1 path: ^1.9.1 - path_provider: ^2.1.5 + path_provider: ^2.1.6 path_provider_foundation: ^2.6.0 permission_handler: ^11.4.0 photo_manager: 3.9.0 @@ -67,13 +67,13 @@ dependencies: share_plus: ^10.1.4 sliver_tools: ^0.2.12 stream_transform: ^2.1.1 - sqlite3: ^3.3.2 + sqlite3: ^3.4.0 sqlite_async: 0.14.2 - sqlite3_connection_pool: ^0.2.6 + sqlite3_connection_pool: ^0.2.7 thumbhash: 0.1.0+1 timezone: ^0.9.4 url_launcher: ^6.3.2 - uuid: ^4.5.3 + uuid: ^4.6.0 wakelock_plus: ^1.3.3 worker_manager: ^7.2.9 web_socket: ^1.0.1 @@ -91,19 +91,21 @@ dependencies: url: https://github.com/mertalev/http ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877 path: pkgs/ok_http/ + freezed_annotation: ^3.1.0 dev_dependencies: auto_route_generator: ^10.5.0 - build_runner: ^2.13.1 + build_runner: ^2.15.1 # Drift generator drift_dev: ^2.34.0 fake_async: ^1.3.3 file: ^7.0.1 # for MemoryFileSystem flutter_launcher_icons: ^0.14.4 - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter_native_splash: ^2.4.7 flutter_test: sdk: flutter + freezed: ^3.2.5 integration_test: sdk: flutter mocktail: ^1.0.5 diff --git a/mobile/scripts/checkout_ios.sh b/mobile/scripts/checkout_ios.sh new file mode 100644 index 0000000000..74706e0d23 --- /dev/null +++ b/mobile/scripts/checkout_ios.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Reconciles the iOS project with the current Flutter setup +# +# `flutter build ios --config-only` is relatively expensive, so we attempt to avoid running it when possible + +set -euo pipefail + +stamp="build/ios_checkout.stamp" + +# All tracked files that can invalidate the Xcode + Flutter build state +inputs_digest() { + git ls-files -z ios pubspec.yaml pubspec.lock mise.toml mise.lock \ + | xargs -0 shasum \ + | shasum \ + | cut -d ' ' -f 1 +} + +# Echoes why the iOS project needs reconciling, or returns 1 if it does not. +# The first three cases can happen with no tracked file having changed at all, +# so comparing the digest alone is not enough. +staleness() { + local flutter_root + flutter_root="$(sed -n 's/^FLUTTER_ROOT=//p' ios/Flutter/Generated.xcconfig 2>/dev/null || true)" + + if [[ -z $flutter_root ]]; then + echo "misconfigured FLUTTER_ROOT" + elif [[ ! -d $flutter_root ]]; then + echo "the Flutter SDK has changed" + elif ! cmp -s ios/Podfile.lock ios/Pods/Manifest.lock; then + # This is also done by Xcode's "Check Pods Manifest.lock" build phase + echo "Cocoapods out of sync" + elif [[ ! -f $stamp ]]; then + echo "first checkout" + elif [[ $(<"$stamp") != "$(inputs_digest)" ]]; then + echo "tracked build state has changed" + else + return 1 + fi +} + +[[ $(uname) == Darwin ]] || exit 0 + +if ! reason="$(staleness)"; then + exit 0 +fi + +echo "Reconciling iOS project: $reason" +flutter build ios --config-only --debug + +# Save tracked files manifest +mkdir -p "$(dirname "$stamp")" +inputs_digest > "$stamp" diff --git a/mobile/test/domain/models/base_asset_test.dart b/mobile/test/domain/models/base_asset_test.dart index 4c2eb3ca38..6a6c836aa7 100644 --- a/mobile/test/domain/models/base_asset_test.dart +++ b/mobile/test/domain/models/base_asset_test.dart @@ -43,4 +43,31 @@ void main() { expect(localOnly.refersToSameAsset(remoteOnly), isTrue); }); }); + + group('BaseAsset.id', () { + test('survives the DB copy filling localId', () { + final searchCopy = RemoteAssetFactory.create(id: 'asset-1'); + final mergedCopy = searchCopy.copyWith(localId: 'local-1'); + + expect(searchCopy.heroTag, isNot(mergedCopy.heroTag)); + expect(mergedCopy.id, searchCopy.id); + }); + + test('survives a local asset gaining a remote id', () { + final localOnly = LocalAssetFactory.create(id: 'local-1'); + final uploaded = localOnly.copyWith(remoteId: 'asset-1'); + + expect(localOnly.heroTag, isNot(uploaded.heroTag)); + expect(uploaded.id, localOnly.id); + }); + + test('differs between assets', () { + final a = RemoteAssetFactory.create(id: 'asset-1'); + final b = RemoteAssetFactory.create(id: 'asset-2'); + final local = LocalAssetFactory.create(id: 'local-1'); + + expect(a.id, isNot(b.id)); + expect(local.id, isNot(a.id)); + }); + }); } diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index 14277709da..2cbabd77e9 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -23,9 +23,9 @@ import '../../service.mocks.dart'; void main() { late LocalSyncService sut; - late DriftLocalAlbumRepository mockLocalAlbumRepository; - late DriftLocalAssetRepository mockLocalAssetRepository; - late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; + late LocalAlbumRepository mockLocalAlbumRepository; + late LocalAssetRepository mockLocalAssetRepository; + late TrashedLocalAssetRepository mockTrashedLocalAssetRepository; late AssetMediaRepository mockAssetMediaRepository; late MockPermissionRepository mockPermissionRepository; late MockNativeSyncApi mockNativeSyncApi; @@ -36,7 +36,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); tearDownAll(() async { diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index bb439b3d72..adab8863c1 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -14,12 +14,12 @@ const _kVersion = 2; void main() { late StoreService sut; - late DriftStoreRepository mockDriftStoreRepo; + late StoreRepository mockDriftStoreRepo; late StreamController>> controller; setUp(() async { controller = StreamController>>.broadcast(); - mockDriftStoreRepo = MockDriftStoreRepository(); + mockDriftStoreRepo = MockStoreRepository(); // For generics, we need to provide fallback to each concrete type to avoid runtime errors registerFallbackValue(StoreKey.accessToken); registerFallbackValue(StoreKey.version); @@ -124,12 +124,12 @@ void main() { group('Store Service delete:', () { setUp(() { - when(() => mockDriftStoreRepo.delete(any>())).thenAnswer((_) async => true); + when(() => mockDriftStoreRepo.deleteValue(any>())).thenAnswer((_) async => true); }); test('Removes the value from the DB', () async { await sut.delete(StoreKey.accessToken); - verify(() => mockDriftStoreRepo.delete(StoreKey.accessToken)).called(1); + verify(() => mockDriftStoreRepo.deleteValue(StoreKey.accessToken)).called(1); }); test('Removes the value from the cache', () async { diff --git a/mobile/test/domain/services/sync_linked_album_service_test.dart b/mobile/test/domain/services/sync_linked_album_service_test.dart index e5f4c3b5dc..60b230ba65 100644 --- a/mobile/test/domain/services/sync_linked_album_service_test.dart +++ b/mobile/test/domain/services/sync_linked_album_service_test.dart @@ -1,9 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; +import 'package:mocktail/mocktail.dart'; import '../../infrastructure/repository.mock.dart'; import '../../service.mocks.dart'; @@ -13,10 +14,12 @@ void main() { // alone, i.e. the root (main) isolate, where cancellationProvider has no // override and throws if read. The UI reads this provider here. ProviderContainer rootContainer() { + final drift = MockDrift(); + when(() => drift.localAlbumRepository).thenReturn(MockLocalAlbumRepository()); + when(() => drift.remoteAlbumRepository).thenReturn(MockRemoteAlbumRepository()); final container = ProviderContainer( overrides: [ - localAlbumRepository.overrideWithValue(MockLocalAlbumRepository()), - remoteAlbumRepository.overrideWithValue(MockRemoteAlbumRepository()), + driftProvider.overrideWithValue(drift), driftAlbumApiRepositoryProvider.overrideWithValue(MockDriftAlbumApiRepository()), storeServiceProvider.overrideWithValue(MockStoreService()), ], diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 5cd4104f91..58b96a2fc6 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -40,8 +40,8 @@ void main() { late SyncStreamService sut; late SyncStreamRepository mockSyncStreamRepo; late SyncApiRepository mockSyncApiRepo; - late DriftLocalAssetRepository mockLocalAssetRepo; - late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo; + late LocalAssetRepository mockLocalAssetRepo; + late TrashedLocalAssetRepository mockTrashedLocalAssetRepo; late AssetMediaRepository mockAssetMediaRepo; late MockPermissionRepository mockPermissionRepo; late MockApiService mockApi; @@ -60,7 +60,7 @@ void main() { registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0)); db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); tearDownAll(() async { diff --git a/mobile/test/drift/main/migration_test.dart b/mobile/test/drift/main/migration_test.dart index 74467492ae..ce3f644b98 100644 --- a/mobile/test/drift/main/migration_test.dart +++ b/mobile/test/drift/main/migration_test.dart @@ -1,5 +1,5 @@ // dart format width=80 -// ignore_for_file: unused_local_variable, unused_import +// ignore_for_file: unused_import import 'package:drift/drift.dart'; import 'package:drift_dev/api/migrations_native.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/mobile/test/infrastructure/loaders/remote_image_request_test.dart b/mobile/test/infrastructure/loaders/remote_image_request_test.dart new file mode 100644 index 0000000000..97bdf3e2bf --- /dev/null +++ b/mobile/test/infrastructure/loaders/remote_image_request_test.dart @@ -0,0 +1,117 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:ffi/ffi.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/platform/remote_image_api.g.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage', + RemoteImageApi.pigeonChannelCodec, + ); + late List args; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, ( + message, + ) async { + args = message! as List; + return [null]; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, null); + }); + + Future loadEncoded(String path, ui.Size decodeSize) async { + final bytes = await File(path).readAsBytes(); + final pointer = malloc(bytes.length)..asTypedList(bytes.length).setAll(0, bytes); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, ( + message, + ) async { + return [ + {'pointer': pointer.address, 'length': bytes.length}, + ]; + }); + final request = RemoteImageRequest(uri: 'https://example.test/fallback', decodeSize: decodeSize); + + return (await request.load((_, {getTargetSize}) => throw UnimplementedError()))!.image; + } + + test('passes the requested decode size to the platform', () async { + final request = RemoteImageRequest(uri: 'https://example.test/thumbnail', decodeSize: const ui.Size(319.1, 179.1)); + + await request.load((_, {getTargetSize}) => throw UnimplementedError()); + + expect(args[0], 'https://example.test/thumbnail'); + expect(args[2], isFalse); + expect(args[3], 320); + expect(args[4], 180); + }); + + test('leaves requests without a size unbounded', () async { + final request = RemoteImageRequest(uri: 'https://example.test/thumbnail'); + + await request.load((_, {getTargetSize}) => throw UnimplementedError()); + + expect(args[3], isNull); + expect(args[4], isNull); + }); + + test('leaves encoded animation requests unbounded', () async { + final request = RemoteImageRequest(uri: 'https://example.test/animation', decodeSize: const ui.Size(320, 180)); + + await request.loadCodec(); + + expect(args[2], isTrue); + expect(args[3], isNull); + expect(args[4], isNull); + }); + + test('keeps portrait cover quality in a wide tile', () async { + final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size(963, 642)); + + expect(image.width, 963); + expect(image.height, 1453); + image.dispose(); + }); + + test('preserves cover quality for extreme aspect ratios', () async { + final image = await loadEncoded('assets/immich-logo-inline-light.png', const ui.Size.square(320)); + + expect(image.width, 1311); + expect(image.height, 320); + image.dispose(); + }); + + test('does not upscale encoded fallback', () async { + final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size.square(2000)); + + expect(image.width, 1206); + expect(image.height, 1819); + image.dispose(); + }); + + test('uses the decode size in the provider cache key', () { + final small = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: const ui.Size.square(160)); + final large = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: const ui.Size.square(320)); + + expect(small, isNot(large)); + }); + + test('shares the cache key when no decode size is set', () { + final first = RemoteImageProvider(url: 'https://example.test/thumbnail'); + final second = RemoteImageProvider(url: 'https://example.test/thumbnail'); + + expect(first, second); + expect(first.hashCode, second.hashCode); + }); +} diff --git a/mobile/test/infrastructure/repositories/local_album_repository_test.dart b/mobile/test/infrastructure/repositories/local_album_repository_test.dart index ada4281e2a..7498360c87 100644 --- a/mobile/test/infrastructure/repositories/local_album_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_album_repository_test.dart @@ -23,7 +23,7 @@ void main() { group('getAll', () { test('sorts albums by backupSelection & isIosSharedAlbum', () async { - final localAlbumRepo = mediumFactory.getRepository(); + final localAlbumRepo = mediumFactory.getRepository(); await localAlbumRepo.upsert(mediumFactory.localAlbum(id: '1', backupSelection: BackupSelection.none)); await localAlbumRepo.upsert(mediumFactory.localAlbum(id: '2', backupSelection: BackupSelection.excluded)); await localAlbumRepo.upsert( @@ -46,7 +46,7 @@ void main() { // Android was dropped. The delta reports only the asset's new album, and the // stale link to its old album made the per-album delete sweep wipe the asset. test('keeps an asset moved to another album that still holds other assets', () async { - final localAlbumRepo = mediumFactory.getRepository(); + final localAlbumRepo = mediumFactory.getRepository(); final moved = _localAsset('moved'); final other = _localAsset('other'); @@ -83,7 +83,7 @@ void main() { }); test('replaces album membership with exactly what the delta reports', () async { - final localAlbumRepo = mediumFactory.getRepository(); + final localAlbumRepo = mediumFactory.getRepository(); final moved = _localAsset('moved'); await localAlbumRepo.upsert(mediumFactory.localAlbum(id: 'src'), toUpsert: [moved]); diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart index 3e160c29ca..08ed5f66ab 100644 --- a/mobile/test/infrastructure/repositories/store_repository_test.dart +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -47,11 +47,11 @@ Future _populateStore(Drift db) async { void main() { late Drift db; - late DriftStoreRepository sut; + late StoreRepository sut; setUp(() async { db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - sut = DriftStoreRepository(db); + sut = StoreRepository(db); }); tearDown(() async { @@ -100,7 +100,7 @@ void main() { test('delete()', () async { bool? advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting); expect(advancedTroubleshooting, isFalse); - await sut.delete(StoreKey.advancedTroubleshooting); + await sut.deleteValue(StoreKey.advancedTroubleshooting); advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting); expect(advancedTroubleshooting, isNull); }); diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 3a3e40cbae..5253838fef 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -41,7 +41,7 @@ void main() { setUpAll(() async { final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); setUp(() { @@ -189,7 +189,7 @@ void main() { final Completer firstBatchReceived = Completer(); final Completer secondBatchReceived = Completer(); - Future onDataCallback(List events, Function() _, Function() __) async { + Future onDataCallback(List events, Function() _, Function() _) async { onDataCallCount++; if (onDataCallCount == 1) { receivedEventsBatch1 = events; @@ -244,7 +244,7 @@ void main() { final streamError = Exception("Network Error"); int onDataCallCount = 0; - Future onDataCallback(List _, Function() _, Function() __) async { + Future onDataCallback(List _, Function() _, Function() _) async { onDataCallCount++; } @@ -270,7 +270,7 @@ void main() { when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(errorBodyController.stream)); int onDataCallCount = 0; - Future onDataCallback(List _, Function() _, Function() __) async { + Future onDataCallback(List _, Function() _, Function() _) async { onDataCallCount++; } diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 355e128fc0..ef266b243c 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,7 +1,9 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; @@ -18,7 +20,11 @@ import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:mocktail/mocktail.dart'; -class MockDriftStoreRepository extends Mock implements DriftStoreRepository {} +class MockDrift extends Mock implements Drift {} + +class MockMemoryRepository extends Mock implements MemoryRepository {} + +class MockStoreRepository extends Mock implements StoreRepository {} class MockSettingsRepository extends Mock implements SettingsRepository {} @@ -26,21 +32,19 @@ class MockLogRepository extends Mock implements LogRepository {} class MockSyncStreamRepository extends Mock implements SyncStreamRepository {} -class MockLocalAlbumRepository extends Mock implements DriftLocalAlbumRepository {} +class MockLocalAlbumRepository extends Mock implements LocalAlbumRepository {} -class MockRemoteAlbumRepository extends Mock implements DriftRemoteAlbumRepository {} +class MockRemoteAlbumRepository extends Mock implements RemoteAlbumRepository {} -class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {} - -class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {} +class MockLocalAssetRepository extends Mock implements LocalAssetRepository {} class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {} -class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {} +class MockTrashedLocalAssetRepository extends Mock implements TrashedLocalAssetRepository {} class MockStorageRepository extends Mock implements StorageRepository {} -class MockDriftBackupRepository extends Mock implements DriftBackupRepository {} +class MockBackupRepository extends Mock implements BackupRepository {} class MockUploadRepository extends Mock implements UploadRepository {} diff --git a/mobile/test/medium/repositories/backup_repository_test.dart b/mobile/test/medium/repositories/backup_repository_test.dart index 00ab28df87..8e56502022 100644 --- a/mobile/test/medium/repositories/backup_repository_test.dart +++ b/mobile/test/medium/repositories/backup_repository_test.dart @@ -7,11 +7,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftBackupRepository sut; + late BackupRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftBackupRepository(ctx.db); + sut = BackupRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/local_asset_repository_test.dart b/mobile/test/medium/repositories/local_asset_repository_test.dart index 2376445d1a..bd048a13fd 100644 --- a/mobile/test/medium/repositories/local_asset_repository_test.dart +++ b/mobile/test/medium/repositories/local_asset_repository_test.dart @@ -8,11 +8,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftLocalAssetRepository sut; + late LocalAssetRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftLocalAssetRepository(ctx.db); + sut = LocalAssetRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/memory_repository_test.dart b/mobile/test/medium/repositories/memory_repository_test.dart index e57098f3bd..af9309f4aa 100644 --- a/mobile/test/medium/repositories/memory_repository_test.dart +++ b/mobile/test/medium/repositories/memory_repository_test.dart @@ -5,11 +5,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftMemoryRepository sut; + late MemoryRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftMemoryRepository(ctx.db); + sut = MemoryRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/partner_repository_test.dart b/mobile/test/medium/repositories/partner_repository_test.dart index 298b8b852d..4d730a0be4 100644 --- a/mobile/test/medium/repositories/partner_repository_test.dart +++ b/mobile/test/medium/repositories/partner_repository_test.dart @@ -78,7 +78,7 @@ void main() { final sharer = await ctx.newUser(); await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id, inTimeline: false); - await sut.update(sharedById: sharer.id, sharedWithId: me.id, inTimeline: true); + await sut.updatePartner(sharedById: sharer.id, sharedWithId: me.id, inTimeline: true); final result = await sut.get(sharedById: sharer.id, sharedWithId: me.id); expect(result.inTimeline, isTrue); @@ -91,7 +91,7 @@ void main() { final recipient = await ctx.newUser(); await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id); - await sut.delete(sharedById: me.id, sharedWithId: recipient.id); + await sut.deletePartner(sharedById: me.id, sharedWithId: recipient.id); final rows = await ctx.db.select(ctx.db.partnerEntity).get(); expect(rows, isEmpty); diff --git a/mobile/test/medium/repositories/people_repository_test.dart b/mobile/test/medium/repositories/people_repository_test.dart index 4b51862a7d..979dd95373 100644 --- a/mobile/test/medium/repositories/people_repository_test.dart +++ b/mobile/test/medium/repositories/people_repository_test.dart @@ -5,11 +5,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftPeopleRepository sut; + late PeopleRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftPeopleRepository(ctx.db); + sut = PeopleRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/remote_album_repository_test.dart b/mobile/test/medium/repositories/remote_album_repository_test.dart index 5a82ff315f..b05a687f16 100644 --- a/mobile/test/medium/repositories/remote_album_repository_test.dart +++ b/mobile/test/medium/repositories/remote_album_repository_test.dart @@ -6,11 +6,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftRemoteAlbumRepository sut; + late RemoteAlbumRepository sut; setUp(() async { ctx = MediumRepositoryContext(); - sut = DriftRemoteAlbumRepository(ctx.db); + sut = RemoteAlbumRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/timeline_repository_test.dart b/mobile/test/medium/repositories/timeline_repository_test.dart index 502a65d45b..bc572b0e2f 100644 --- a/mobile/test/medium/repositories/timeline_repository_test.dart +++ b/mobile/test/medium/repositories/timeline_repository_test.dart @@ -7,7 +7,7 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftTimelineRepository sut; + late TimelineRepository sut; setUpAll(() async { await initializeDateFormatting(); @@ -15,7 +15,7 @@ void main() { setUp(() { ctx = MediumRepositoryContext(); - sut = DriftTimelineRepository(ctx.db); + sut = TimelineRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart b/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart index 0af09715c7..532502ca3d 100644 --- a/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart +++ b/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart @@ -6,11 +6,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftTrashedLocalAssetRepository sut; + late TrashedLocalAssetRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftTrashedLocalAssetRepository(ctx.db); + sut = TrashedLocalAssetRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/modules/map/map_theme_override_test.dart b/mobile/test/modules/map/map_theme_override_test.dart index 817ce0c4b1..3ea54bc9b5 100644 --- a/mobile/test/modules/map/map_theme_override_test.dart +++ b/mobile/test/modules/map/map_theme_override_test.dart @@ -32,7 +32,7 @@ void main() { setUp(() async { mapState = const MapState(themeMode: ThemeMode.dark); mapStateNotifier = MockMapStateNotifier(mapState); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); overrides = [ mapStateNotifierProvider.overrideWith(() => mapStateNotifier), localeProvider.overrideWithValue(const Locale("en")), diff --git a/mobile/test/pages/common/download_panel_test.dart b/mobile/test/pages/common/download_panel_test.dart new file mode 100644 index 0000000000..f2a6b09d88 --- /dev/null +++ b/mobile/test/pages/common/download_panel_test.dart @@ -0,0 +1,24 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/pages/common/download_panel.dart'; + +import '../../widget_tester_extensions.dart'; + +void main() { + testWidgets('complete uses a checkmark and other statuses use close', (tester) async { + await tester.pumpConsumerWidget( + DownloadTaskTile(progress: 1, fileName: 'photo.jpg', status: TaskStatus.complete, onCancelDownload: () {}), + ); + + expect(find.byIcon(Icons.download_done), findsOneWidget); + expect(find.byIcon(Icons.close), findsNothing); + + await tester.pumpConsumerWidget( + DownloadTaskTile(progress: 1, fileName: 'photo.jpg', status: TaskStatus.failed, onCancelDownload: () {}), + ); + + expect(find.byIcon(Icons.download_done), findsNothing); + expect(find.byIcon(Icons.close), findsOneWidget); + }); +} diff --git a/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart b/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart index d52f6b6b83..f4ee4f353c 100644 --- a/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart +++ b/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart @@ -20,7 +20,7 @@ class _SwappableTimelineService extends TimelineService { BaseAsset asset; _SwappableTimelineService(this.asset) : super(( - assetSource: (_, __) async => [], + assetSource: (_, _) async => [], bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]), origin: TimelineOrigin.main, )); diff --git a/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart b/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart new file mode 100644 index 0000000000..f2463c090d --- /dev/null +++ b/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/services/gcast.service.dart'; +import 'package:immich_mobile/utils/asset_filter.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:native_video_player/native_video_player.dart'; + +import '../../../service.mocks.dart'; +import '../../../unit/factories/remote_asset_factory.dart'; +import '../../../widget_tester_extensions.dart'; + +class MockNativeVideoPlayerController extends Mock implements NativeVideoPlayerController {} + +class MockTimelineService extends Mock implements TimelineService {} + +class TestReadOnlyModeNotifier extends ReadOnlyModeNotifier { + @override + bool build() => true; +} + +void main() { + testWidgets('player stays bound after local id arrives', (tester) async { + final searchCopy = RemoteAssetFactory.create(type: .video); + final mergedCopy = searchCopy.copyWith(localId: 'local-1', isFavorite: true); + final assetService = MockAssetService(); + final controller = MockNativeVideoPlayerController(); + final timeline = MockTimelineService(); + final updates = StreamController(sync: true); + when(() => assetService.watchAsset(searchCopy)).thenAnswer((_) => updates.stream); + when(controller.play).thenAnswer((_) async {}); + when(controller.pause).thenAnswer((_) async {}); + when(() => timeline.origin).thenReturn(.search); + + late WidgetRef ref; + + await tester.pumpConsumerWidget( + Consumer( + builder: (context, widgetRef, _) { + ref = widgetRef; + return const ViewerBottomBar(); + }, + ), + overrides: [ + assetServiceProvider.overrideWithValue(assetService), + assetsActionProvider(ActionSource.viewer).overrideWithValue(const AssetFilter({})), + gCastServiceProvider.overrideWithValue(MockGCastService()), + inLockedViewProvider.overrideWithValue(true), + ownedAssetsActionProvider(ActionSource.viewer).overrideWithValue(const AssetFilter({})), + readonlyModeProvider.overrideWith(TestReadOnlyModeNotifier.new), + timelineServiceProvider.overrideWithValue(timeline), + ], + ); + + final viewer = ref.read(assetViewerProvider.notifier); + viewer.setAsset(searchCopy); + await tester.pump(); + ref.read(videoPlayerProvider(searchCopy.id).notifier).attachController(controller); + updates.add(mergedCopy); + await tester.pump(); + await tester.tap(find.byType(IconButton)); + await tester.pump(); + + verify(controller.play).called(1); + viewer.setShowingDetails(true); + await tester.pump(); + verify(controller.pause).called(1); + + await tester.pumpWidget(const SizedBox.shrink()); + unawaited(updates.close()); + }); +} diff --git a/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart b/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart index 02bb0c1053..3a5ad159b5 100644 --- a/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart +++ b/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart @@ -29,7 +29,7 @@ void main() { setUp(() { // Create a fresh, real Flutter ImageCache for every test cache = ImageCache(); - uiListener = ImageStreamListener((_, __) {}); + uiListener = ImageStreamListener((_, _) {}); }); group('CacheAwareListenerTrackerMixin with Real ImageCache', () { @@ -130,7 +130,7 @@ void main() { final stream = cache.putIfAbsent(key, () => completer)!; - final uiListener2 = ImageStreamListener((_, __) {}); + final uiListener2 = ImageStreamListener((_, _) {}); stream.addListener(uiListener); stream.addListener(uiListener2); @@ -157,7 +157,7 @@ void main() { expect(completer.wasCancelled, isFalse); // A second UI listener attaches — must NOT be treated as cache - final uiListener2 = ImageStreamListener((_, __) {}); + final uiListener2 = ImageStreamListener((_, _) {}); stream.addListener(uiListener2); // Remove first UI listener; second UI still active → no cancel diff --git a/mobile/test/presentation/widgets/images/local_image_provider_test.dart b/mobile/test/presentation/widgets/images/local_image_provider_test.dart new file mode 100644 index 0000000000..5b556f8a41 --- /dev/null +++ b/mobile/test/presentation/widgets/images/local_image_provider_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; + +import '../../../unit/factories/local_asset_factory.dart'; + +class _StubCompleter extends ImageStreamCompleter {} + +void main() { + late ImageCache cache; + late int loads; + + ImageStreamCompleter load() { + loads++; + return _StubCompleter(); + } + + setUp(() { + cache = ImageCache(); + loads = 0; + }); + + group('LocalFullImageProvider.previewTargetSize', () { + const box = Size(1179, 2556); + const cases = <(String, Size, int?, int?, bool, Size)>[ + ('normal', box, 4032, 3024, true, box), + ('missing dimensions', box, null, null, true, box), + ('invalid dimensions', box, 1000, 0, true, box), + ('long final preview', box, 1000, 30000, true, Size(16384 / 30, 16384)), + ('long first preview', box, 30000, 1000, false, Size(2556, 2556 / 30)), + ('ultra-thin preview', box, 10, 50000, false, Size(1, 2556)), + ('small source', box, 50, 2000, true, Size(50, 2000)), + ('at limit', Size(16384, 100), 1000, 1000, true, Size(16384, 100)), + ('over limit', Size(16385, 100), 1000, 1000, true, Size(1000, 1000)), + ]; + + for (final (name, box, width, height, previewIsFinal, expected) in cases) { + test(name, () { + final actual = LocalFullImageProvider.previewTargetSize( + box.width, + box.height, + width, + height, + previewIsFinal: previewIsFinal, + ); + expect(actual.width, closeTo(expected.width, 1e-6)); + expect(actual.height, closeTo(expected.height, 1e-6)); + }); + } + }); + + group('LocalFullImageProvider equality', () { + LocalFullImageProvider make({int? width, int? height}) => LocalFullImageProvider( + id: 'a', + assetType: AssetType.image, + size: const Size(100, 200), + isAnimated: false, + width: width, + height: height, + ); + + test('uses dimensions in the cache key', () { + final a = make(width: 100, height: 200); + final b = make(width: 100, height: 200); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a == make(width: 200, height: 100), isFalse); + }); + }); + + group('LocalThumbProvider caching', () { + test('editing on device re-renders the thumbnail', () { + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load); + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'after'), load); + + expect(loads, 2); + }); + + test('an unchanged thumbnail still comes from the cache', () { + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load); + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load); + + expect(loads, 1); + }); + + // The rehash clears the checksum before writing the new one, so the tile has to + // follow that step too or it waits for the hash to land before showing the edit. + test('re-renders while the checksum is still being recomputed', () { + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load); + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); + + expect(loads, 2); + }); + + test('stays cached while the checksum is missing', () { + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); + + expect(loads, 1); + }); + }); + + group('factories', () { + test('thumbnails are keyed by the asset checksum', () { + final asset = LocalAssetFactory.create().copyWith(checksum: 'abc'); + + final provider = getThumbnailImageProvider(asset)! as LocalThumbProvider; + + expect(provider.checksum, 'abc'); + }); + }); + + group('LocalFullImageProvider caching', () { + test('editing on device re-renders the full image', () { + cache.putIfAbsent( + LocalFullImageProvider( + id: 'asset-1', + assetType: AssetType.image, + size: const Size(100, 100), + isAnimated: false, + checksum: 'before', + ), + load, + ); + cache.putIfAbsent( + LocalFullImageProvider( + id: 'asset-1', + assetType: AssetType.image, + size: const Size(100, 100), + isAnimated: false, + checksum: 'after', + ), + load, + ); + + expect(loads, 2); + }); + }); +} diff --git a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart index 5c03998bfc..439ae9ecf1 100644 --- a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart +++ b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart @@ -64,7 +64,7 @@ void main() { TimelineArgs? probed; final probe = Consumer( - builder: (_, ref, __) { + builder: (_, ref, _) { probed = ref.watch(timelineArgsProvider); return const SizedBox.shrink(); }, @@ -103,7 +103,7 @@ void main() { TimelineArgs? probed; final probe = SliverToBoxAdapter( child: Consumer( - builder: (_, ref, __) { + builder: (_, ref, _) { probed = ref.watch(timelineArgsProvider); return const SizedBox.shrink(); }, @@ -148,7 +148,7 @@ void main() { TimelineArgs? probed; final probe = Consumer( - builder: (_, ref, __) { + builder: (_, ref, _) { probed = ref.watch(timelineArgsProvider); return const SizedBox.shrink(); }, diff --git a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart index 28644d6702..e85ced8fb1 100644 --- a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart +++ b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import '../../../fixtures/asset.stub.dart'; @@ -50,7 +51,7 @@ void main() { page: PageInfo('Overlay', builder: (_) => const Scaffold(body: SizedBox.shrink())), type: RouteType.custom( customRouteBuilder: (_, child, page) => - PageRouteBuilder(settings: page, opaque: false, pageBuilder: (_, __, ___) => child), + PageRouteBuilder(settings: page, opaque: false, pageBuilder: (_, _, _) => child), ), ), ], @@ -62,7 +63,9 @@ void main() { timelineServiceProvider.overrideWithValue(service), appConfigProvider.overrideWithValue(const AppConfig()), ], - child: MaterialApp.router(routerConfig: router.config()), + child: MaterialApp.router( + routerConfig: router.config(navigatorObservers: () => [TransitioningRouteObserver()]), + ), ), ); // Segment stream resolves @@ -96,9 +99,14 @@ void main() { await tester.pumpAndSettle(); expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route'); - // Once the timeline is visible, taps on the status bar should scroll to the top - await router.maybePop(); + // The tap is async, so it can arrive while a pushed route is transitioning to popped + unawaited(router.maybePop()); + await tester.pump(); + await tapStatusBar(); await tester.pumpAndSettle(); + expect(position.pixels, initialScrolledPosition, reason: 'ignored while the pushed route pops'); + + // Once the timeline is visible, taps on the status bar should scroll to the top await tapStatusBar(); await tester.pumpAndSettle(); expect(position.pixels, 0, reason: 'scrolls the foreground timeline'); diff --git a/mobile/test/providers/app_life_cycle_provider_test.dart b/mobile/test/providers/app_life_cycle_provider_test.dart new file mode 100644 index 0000000000..d4489a58ba --- /dev/null +++ b/mobile/test/providers/app_life_cycle_provider_test.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/config/app_config.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/models/server_info/server_version.model.dart'; +import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/utils/upload_speed_calculator.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../infrastructure/repository.mock.dart'; +import '../service.mocks.dart'; + +class FakeLogMessage extends Fake implements LogMessage {} + +class TestAuthNotifier extends AuthNotifier { + TestAuthNotifier(Ref ref) + : super( + MockAuthService(), + MockApiService(), + MockUserService(), + MockSecureStorageService(), + MockWidgetService(), + ref, + ) { + state = const AuthState( + deviceId: 'device-1', + userId: 'user-1', + userEmail: 'user@example.com', + name: 'User', + profileImagePath: '', + isAdmin: false, + isAuthenticated: true, + ); + } + + @override + Future setOpenApiServiceEndpoint() async => 'http://test-server.com'; +} + +class TestWebsocketNotifier extends WebsocketNotifier { + TestWebsocketNotifier(super.ref); + + int connectCount = 0; + int disconnectCount = 0; + final connectCalled = Completer(); + + @override + void connect() { + connectCount++; + if (!connectCalled.isCompleted) { + connectCalled.complete(); + } + throw StateError('unexpected websocket connection'); + } + + @override + void disconnect() => disconnectCount++; +} + +class TestDriftBackupNotifier extends DriftBackupNotifier { + TestDriftBackupNotifier() : super(MockForegroundUploadService(), MockBackgroundUploadService(), UploadSpeedManager()); +} + +void main() { + late LogService logService; + late Completer serverVersion; + late MockServerInfoService serverInfoService; + late MockBackgroundWorkerLockService lockService; + late ProviderContainer container; + late TestWebsocketNotifier websocket; + late AppLifeCycleNotifier lifeCycle; + late int serverVersionCount; + + setUpAll(() async { + final logRepository = MockLogRepository(); + final settingsRepository = MockSettingsRepository(); + registerFallbackValue(FakeLogMessage()); + when(() => logRepository.truncate(limit: any(named: 'limit'))).thenAnswer((_) async {}); + when(() => logRepository.insert(any())).thenAnswer((_) async => true); + when(() => settingsRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.info)); + logService = await LogService.init( + logRepository: logRepository, + settingsRepository: settingsRepository, + shouldBuffer: false, + ); + }); + + tearDownAll(() => logService.dispose()); + + setUp(() { + serverVersion = Completer(); + serverInfoService = MockServerInfoService(); + lockService = MockBackgroundWorkerLockService(); + serverVersionCount = 0; + + when(() => serverInfoService.getServerVersion()).thenAnswer((_) { + serverVersionCount++; + return serverVersionCount == 1 ? serverVersion.future : Future.value(); + }); + when(() => lockService.lock()).thenAnswer((_) async {}); + when(() => lockService.unlock()).thenAnswer((_) async {}); + + container = ProviderContainer( + overrides: [ + authProvider.overrideWith(TestAuthNotifier.new), + serverInfoProvider.overrideWith((_) => ServerInfoNotifier(serverInfoService)), + websocketProvider.overrideWith((ref) { + return websocket = TestWebsocketNotifier(ref); + }), + driftBackupProvider.overrideWith((_) => TestDriftBackupNotifier()), + backgroundWorkerLockServiceProvider.overrideWithValue(lockService), + ], + ); + lifeCycle = container.read(appStateProvider.notifier); + }); + + tearDown(() => container.dispose()); + + Future startResume() async { + await lifeCycle.handleAppPause(); + unawaited(lifeCycle.handleAppResume()); + await untilCalled(() => serverInfoService.getServerVersion()); + } + + Future releaseResume() async { + serverVersion.complete(); + await Future.delayed(Duration.zero); + } + + test('pause during resume does not reconnect websocket', () async { + await startResume(); + await lifeCycle.handleAppPause(); + await releaseResume(); + + expect(lifeCycle.getAppState(), AppLifeCycleEnum.paused); + expect(serverVersionCount, 1); + expect(websocket.disconnectCount, 2); + expect(websocket.connectCount, 0); + }); + + test('inactive resume retries when the app resumes again', () async { + await startResume(); + lifeCycle.handleAppInactivity(); + await releaseResume(); + + unawaited(lifeCycle.handleAppResume()); + await websocket.connectCalled.future; + + expect(lifeCycle.getAppState(), AppLifeCycleEnum.resumed); + expect(serverVersionCount, 2); + expect(websocket.disconnectCount, 1); + expect(websocket.connectCount, 1); + }); + + test('pause after an inactive abort resumes once', () async { + await startResume(); + lifeCycle.handleAppInactivity(); + await releaseResume(); + await lifeCycle.handleAppPause(); + + unawaited(lifeCycle.handleAppResume()); + await websocket.connectCalled.future; + unawaited(lifeCycle.handleAppResume()); + await Future.delayed(Duration.zero); + + expect(lifeCycle.getAppState(), AppLifeCycleEnum.resumed); + expect(serverVersionCount, 2); + expect(websocket.disconnectCount, 2); + expect(websocket.connectCount, 1); + }); +} diff --git a/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart b/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart index 27033a5774..454923affa 100644 --- a/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart +++ b/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -107,5 +108,20 @@ void main() { await pumpEventQueue(); expect(container.read(assetViewerProvider).currentAsset, updatedTwo); }); + + test('keeps the tapped tile size while swiping', () { + final first = RemoteAssetFactory.create(); + final second = RemoteAssetFactory.create(); + final notifier = container.read(assetViewerProvider.notifier); + + notifier.setAsset(first, thumbnailSize: const Size.square(642)); + expect(container.read(assetViewerProvider).thumbnailSize, const Size.square(642)); + + notifier.setAsset(second); + expect(container.read(assetViewerProvider).thumbnailSize, const Size.square(642)); + + notifier.reset(); + expect(container.read(assetViewerProvider).thumbnailSize, isNull); + }); }); } diff --git a/mobile/test/providers/asset_viewer/download_provider_test.dart b/mobile/test/providers/asset_viewer/download_provider_test.dart new file mode 100644 index 0000000000..2e2694475d --- /dev/null +++ b/mobile/test/providers/asset_viewer/download_provider_test.dart @@ -0,0 +1,113 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockDownloadService extends Mock implements DownloadService {} + +DownloadTask _task(String id, {String filename = 'photo.jpg', String metaData = ''}) => + DownloadTask(taskId: id, url: 'https://example.com/$filename', filename: filename, metaData: metaData); + +void main() { + late MockDownloadService service; + late DownloadStateNotifier notifier; + late void Function(TaskProgressUpdate) onProgress; + late void Function(TaskStatusUpdate) onImage; + late void Function(TaskStatusUpdate) onLivePhoto; + + setUp(() { + service = MockDownloadService(); + notifier = DownloadStateNotifier(service); + addTearDown(() { + if (notifier.mounted) { + notifier.dispose(); + } + }); + + onProgress = verify(() => service.onTaskProgress = captureAny()).captured.last as void Function(TaskProgressUpdate); + onImage = + verify(() => service.onImageDownloadStatus = captureAny()).captured.last as void Function(TaskStatusUpdate); + onLivePhoto = + verify(() => service.onLivePhotoDownloadStatus = captureAny()).captured.last as void Function(TaskStatusUpdate); + }); + + test('complete flips the entry and keeps it visible', () { + fakeAsync((async) { + final task = _task('task-1'); + onProgress(TaskProgressUpdate(task, 0.4)); + onImage(TaskStatusUpdate(task, TaskStatus.complete)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.complete); + expect(notifier.state.showProgress, isTrue); + + async.elapse(const Duration(seconds: 5)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.complete); + expect(notifier.state.showProgress, isTrue); + }); + }); + + test('failed keeps the entry visible', () { + fakeAsync((async) { + final task = _task('task-1'); + onProgress(TaskProgressUpdate(task, 0.4)); + onImage(TaskStatusUpdate(task, TaskStatus.failed)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.failed); + + async.elapse(const Duration(seconds: 5)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.failed); + expect(notifier.state.showProgress, isTrue); + }); + }); + + test('a live photo part completion keeps both entries', () { + fakeAsync((async) { + final image = _task( + 'live-image', + metaData: LivePhotosMetadata(part: LivePhotosPart.image, id: 'live-1').toJson(), + ); + final video = _task( + 'live-video', + filename: 'photo.MOV', + metaData: LivePhotosMetadata(part: LivePhotosPart.video, id: 'live-1').toJson(), + ); + onProgress(TaskProgressUpdate(image, 0.9)); + onProgress(TaskProgressUpdate(video, 0.9)); + onLivePhoto(TaskStatusUpdate(image, TaskStatus.complete)); + + async.elapse(const Duration(seconds: 5)); + + expect(notifier.state.taskProgress['live-image']?.status, TaskStatus.complete); + expect(notifier.state.taskProgress['live-video']?.status, TaskStatus.running); + expect(notifier.state.showProgress, isTrue); + }); + }); + + test('canceled does not resurrect or alter the entry', () { + fakeAsync((async) { + final task = _task('task-1'); + onProgress(TaskProgressUpdate(task, 0.4)); + onImage(TaskStatusUpdate(task, TaskStatus.canceled)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.running); + expect(notifier.state.taskProgress['task-1']?.progress, 0.4); + + onImage(TaskStatusUpdate(_task('ghost'), TaskStatus.canceled)); + expect(notifier.state.taskProgress.containsKey('ghost'), isFalse); + }); + }); + + test('a status for an unknown task does not create an entry', () { + fakeAsync((async) { + onImage(TaskStatusUpdate(_task('ghost'), TaskStatus.complete)); + + expect(notifier.state.taskProgress, isEmpty); + expect(notifier.state.showProgress, isFalse); + }); + }); +} diff --git a/mobile/test/providers/backup/drift_backup_provider_test.dart b/mobile/test/providers/backup/drift_backup_provider_test.dart new file mode 100644 index 0000000000..205f564cd9 --- /dev/null +++ b/mobile/test/providers/backup/drift_backup_provider_test.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/utils/upload_speed_calculator.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockForegroundUploadService extends Mock implements ForegroundUploadService {} + +class MockBackgroundUploadService extends Mock implements BackgroundUploadService {} + +void main() { + late MockForegroundUploadService foregroundUploadService; + late MockBackgroundUploadService backgroundUploadService; + late DriftBackupNotifier notifier; + + setUpAll(() { + registerFallbackValue(Completer()); + registerFallbackValue(const UploadCallbacks()); + }); + + setUp(() { + foregroundUploadService = MockForegroundUploadService(); + backgroundUploadService = MockBackgroundUploadService(); + notifier = DriftBackupNotifier(foregroundUploadService, backgroundUploadService, UploadSpeedManager()); + addTearDown(() { + if (notifier.mounted) { + notifier.dispose(); + } + }); + }); + + void mockCounts({required int total, required int remainder, int processing = 0}) { + when( + () => foregroundUploadService.getBackupCounts('user-1'), + ).thenAnswer((_) async => (total: total, remainder: remainder, processing: processing)); + } + + // Drives a backup run so we can grab the onSuccess callback the notifier wires up. + Future startAndCaptureOnSuccess() async { + void Function(String, String)? onSuccess; + when(() => foregroundUploadService.uploadCandidates(any(), any(), callbacks: any(named: 'callbacks'))).thenAnswer(( + invocation, + ) async { + onSuccess = (invocation.namedArguments[#callbacks] as UploadCallbacks).onSuccess; + }); + await notifier.startForegroundBackup('user-1'); + return onSuccess!; + } + + group('foreground backup counts', () { + test('successes move one asset from remainder to backup', () async { + mockCounts(total: 25, remainder: 25); + final onSuccess = await startAndCaptureOnSuccess(); + + for (var i = 0; i < 10; i++) { + onSuccess('asset-$i', 'remote-$i'); + } + + expect(notifier.state.remainderCount, 15); + expect(notifier.state.backupCount, 10); + expect(notifier.state.backupCount + notifier.state.remainderCount, notifier.state.totalCount); + }); + + test('a duplicate success after pause and resume cannot go below zero', () async { + // #26215: app pauses mid-backup, sync has not recorded the upload yet, so the + // resumed run re-uploads the same asset and the server answers 200 duplicate. + // The start of each run re-baselines the counters from the DB, so the duplicate + // success is counted against a baseline that includes the asset again. + mockCounts(total: 1, remainder: 1); + + final firstRun = await startAndCaptureOnSuccess(); + expect(notifier.state.remainderCount, 1); + firstRun('asset-1', 'remote-1'); + expect(notifier.state.remainderCount, 0); + + notifier.stopForegroundBackup(reason: "test"); + + final resumedRun = await startAndCaptureOnSuccess(); + expect(notifier.state.remainderCount, 1); + verify(() => foregroundUploadService.getBackupCounts('user-1')).called(2); + + resumedRun('asset-1', 'remote-1'); + expect(notifier.state.remainderCount, 0); + expect(notifier.state.backupCount, 1); + }); + + test('a drifted counter state heals at run start', () async { + mockCounts(total: 91, remainder: 7); + notifier.state = notifier.state.copyWith(totalCount: 91, backupCount: 103, remainderCount: -12); + + await startAndCaptureOnSuccess(); + + expect(notifier.state.totalCount, 91); + expect(notifier.state.remainderCount, 7); + expect(notifier.state.backupCount, 84); + }); + + test('a late success after dispose does not throw', () async { + mockCounts(total: 2, remainder: 2); + final onSuccess = await startAndCaptureOnSuccess(); + notifier.dispose(); + + onSuccess('asset-1', 'remote-1'); + }); + }); +} diff --git a/mobile/test/providers/infrastructure/memory_provider_test.dart b/mobile/test/providers/infrastructure/memory_provider_test.dart index e588153fc9..18e481573e 100644 --- a/mobile/test/providers/infrastructure/memory_provider_test.dart +++ b/mobile/test/providers/infrastructure/memory_provider_test.dart @@ -2,18 +2,20 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; -import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:mocktail/mocktail.dart'; -class MockDriftMemoryService extends Mock implements DriftMemoryService {} +import '../../infrastructure/repository.mock.dart'; class MockUserService extends Mock implements UserService {} void main() { - late MockDriftMemoryService memoryService; + late MockMemoryRepository memoryRepository; late MockUserService userService; UserDto user({bool memoryEnabled = true}) => UserDto( @@ -24,10 +26,16 @@ void main() { profileChangedAt: DateTime(2026), ); + Drift mockDrift(MemoryRepository repository) { + final drift = MockDrift(); + when(() => drift.memoryRepository).thenReturn(repository); + return drift; + } + ProviderContainer makeContainer() { final container = ProviderContainer( overrides: [ - driftMemoryServiceProvider.overrideWithValue(memoryService), + driftProvider.overrideWithValue(mockDrift(memoryRepository)), currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)), ], ); @@ -36,10 +44,10 @@ void main() { } setUp(() { - memoryService = MockDriftMemoryService(); + memoryRepository = MockMemoryRepository(); userService = MockUserService(); - when(() => memoryService.getMemoryLane('user-1')).thenAnswer((_) async => []); + when(() => memoryRepository.getAll('user-1')).thenAnswer((_) async => []); when(() => userService.tryGetMyUser()).thenReturn(user()); when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty()); }); @@ -48,33 +56,33 @@ void main() { test('re-queries after local midnight', () { fakeAsync((async) { final container = makeContainer(); - container.listen(driftMemoryFutureProvider, (_, __) {}); + container.listen(driftMemoryFutureProvider, (_, _) {}); async.flushMicrotasks(); - verify(() => memoryService.getMemoryLane('user-1')).called(1); + verify(() => memoryRepository.getAll('user-1')).called(1); async.elapse(const Duration(seconds: 4)); async.flushMicrotasks(); - verifyNever(() => memoryService.getMemoryLane('user-1')); + verifyNever(() => memoryRepository.getAll('user-1')); async.elapse(const Duration(hours: 25)); async.flushMicrotasks(); - verify(() => memoryService.getMemoryLane('user-1')).called(greaterThanOrEqualTo(1)); + verify(() => memoryRepository.getAll('user-1')).called(greaterThanOrEqualTo(1)); }); }); test('cancels the midnight timer when disposed', () { fakeAsync((async) { final container = makeContainer(); - final subscription = container.listen(driftMemoryFutureProvider, (_, __) {}); + final subscription = container.listen(driftMemoryFutureProvider, (_, _) {}); async.flushMicrotasks(); - verify(() => memoryService.getMemoryLane('user-1')).called(1); + verify(() => memoryRepository.getAll('user-1')).called(1); subscription.close(); async.elapse(const Duration(hours: 25)); async.flushMicrotasks(); - verifyNever(() => memoryService.getMemoryLane('user-1')); + verifyNever(() => memoryRepository.getAll('user-1')); }); }); @@ -83,13 +91,13 @@ void main() { fakeAsync((async) { final container = makeContainer(); - container.listen(driftMemoryFutureProvider, (_, __) {}); + container.listen(driftMemoryFutureProvider, (_, _) {}); async.flushMicrotasks(); async.elapse(const Duration(hours: 25)); async.flushMicrotasks(); - verifyNever(() => memoryService.getMemoryLane(any())); + verifyNever(() => memoryRepository.getAll(any())); }); }); }); diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index b56a8a098a..164e7fe062 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -12,7 +12,7 @@ class MockAssetApiRepository extends Mock implements AssetApiRepository {} class MockAssetMediaRepository extends Mock implements AssetMediaRepository {} -class MockPermissionRepository extends Mock implements IPermissionRepository {} +class MockPermissionRepository extends Mock implements DevicePermissionRepository {} class MockAuthApiRepository extends Mock implements AuthApiRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index 785567de56..ecf9c771bc 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/domain/services/asset.service.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; @@ -7,12 +8,16 @@ import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/gcast.service.dart'; import 'package:immich_mobile/services/network.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/services/toast.service.dart'; +import 'package:immich_mobile/services/widget.service.dart'; import 'package:mocktail/mocktail.dart'; class MockApiService extends Mock implements ApiService {} @@ -46,3 +51,13 @@ class MockCleanupService extends Mock implements CleanupService {} class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} class MockToastService extends Mock implements ToastService {} + +class MockAuthService extends Mock implements AuthService {} + +class MockSecureStorageService extends Mock implements SecureStorageService {} + +class MockWidgetService extends Mock implements WidgetService {} + +class MockBackgroundUploadService extends Mock implements BackgroundUploadService {} + +class MockBackgroundWorkerLockService extends Mock implements BackgroundWorkerLockService {} diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index 2d90e24503..b008ff9bfc 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -25,7 +25,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); tearDownAll(() async { diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index b62ad3ad2c..6d79993aa8 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -36,7 +36,7 @@ void main() { setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); tearDownAll(() async { diff --git a/mobile/test/services/background_upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart index aa46bd9de3..2d9b5b1209 100644 --- a/mobile/test/services/background_upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -25,8 +25,8 @@ void main() { late BackgroundUploadService sut; late MockUploadRepository mockUploadRepository; late MockStorageRepository mockStorageRepository; - late MockDriftLocalAssetRepository mockLocalAssetRepository; - late MockDriftBackupRepository mockBackupRepository; + late MockLocalAssetRepository mockLocalAssetRepository; + late MockBackupRepository mockBackupRepository; late MockAssetMediaRepository mockAssetMediaRepository; late Drift db; @@ -37,7 +37,7 @@ void main() { (MethodCall methodCall) async => 'test', ); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); await SettingsRepository.ensureInitialized(db); await Store.put(StoreKey.serverEndpoint, 'http://test-server.com'); @@ -47,8 +47,8 @@ void main() { setUp(() { mockUploadRepository = MockUploadRepository(); mockStorageRepository = MockStorageRepository(); - mockLocalAssetRepository = MockDriftLocalAssetRepository(); - mockBackupRepository = MockDriftBackupRepository(); + mockLocalAssetRepository = MockLocalAssetRepository(); + mockBackupRepository = MockBackupRepository(); mockAssetMediaRepository = MockAssetMediaRepository(); sut = BackgroundUploadService( diff --git a/mobile/test/services/cleanup.service_test.dart b/mobile/test/services/cleanup.service_test.dart index 2038941ecb..294b8550ea 100644 --- a/mobile/test/services/cleanup.service_test.dart +++ b/mobile/test/services/cleanup.service_test.dart @@ -9,11 +9,11 @@ import '../repository.mocks.dart'; void main() { late CleanupService sut; - late MockDriftLocalAssetRepository localAssetRepository; + late MockLocalAssetRepository localAssetRepository; late MockAssetMediaRepository assetMediaRepository; setUp(() { - localAssetRepository = MockDriftLocalAssetRepository(); + localAssetRepository = MockLocalAssetRepository(); assetMediaRepository = MockAssetMediaRepository(); sut = CleanupService(localAssetRepository, assetMediaRepository); }); @@ -24,7 +24,7 @@ void main() { expect(result, 0); verifyNever(() => assetMediaRepository.deleteAll(any())); - verifyNever(() => localAssetRepository.delete(any())); + verifyNever(() => localAssetRepository.deleteAssets(any())); }); test('deletes in a single batch when under limit', () async { @@ -33,13 +33,13 @@ void main() { when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async { return (invocation.positionalArguments.first as List).toList(); }); - when(() => localAssetRepository.delete(any())).thenAnswer((_) async {}); + when(() => localAssetRepository.deleteAssets(any())).thenAnswer((_) async {}); final result = await sut.deleteLocalAssets(ids); expect(result, ids.length); verify(() => assetMediaRepository.deleteAll(ids)).called(1); - verify(() => localAssetRepository.delete(ids)).called(1); + verify(() => localAssetRepository.deleteAssets(ids)).called(1); }); test('deletes in platform-specific batches when over limit', () async { @@ -52,7 +52,7 @@ void main() { capturedBatches.add(batch); return batch; }); - when(() => localAssetRepository.delete(any())).thenAnswer((_) async {}); + when(() => localAssetRepository.deleteAssets(any())).thenAnswer((_) async {}); final result = await sut.deleteLocalAssets(ids); @@ -67,7 +67,7 @@ void main() { expect(capturedBatches[1].last, 'asset-${batchSize * 2 - 1}'); expect(capturedBatches[2].first, 'asset-${batchSize * 2}'); expect(capturedBatches[2].last, 'asset-${batchSize * 2 + 500}'); - verify(() => localAssetRepository.delete(any())).called(3); + verify(() => localAssetRepository.deleteAssets(any())).called(3); }); }); } diff --git a/mobile/test/services/foreground_upload.service_test.dart b/mobile/test/services/foreground_upload.service_test.dart index d4344ed77e..4f0a01dfd0 100644 --- a/mobile/test/services/foreground_upload.service_test.dart +++ b/mobile/test/services/foreground_upload.service_test.dart @@ -24,7 +24,7 @@ void main() { late ForegroundUploadService sut; late MockUploadRepository mockUploadRepository; late MockStorageRepository mockStorageRepository; - late MockDriftBackupRepository mockBackupRepository; + late MockBackupRepository mockBackupRepository; late MockConnectivityApi mockConnectivityApi; late MockAssetMediaRepository mockAssetMediaRepository; late Drift db; @@ -36,7 +36,7 @@ void main() { (MethodCall methodCall) async => 'test', ); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); await SettingsRepository.ensureInitialized(db); await Store.put(StoreKey.serverEndpoint, 'http://demo.immich.app'); @@ -49,7 +49,7 @@ void main() { setUp(() { mockUploadRepository = MockUploadRepository(); mockStorageRepository = MockStorageRepository(); - mockBackupRepository = MockDriftBackupRepository(); + mockBackupRepository = MockBackupRepository(); mockConnectivityApi = MockConnectivityApi(); mockAssetMediaRepository = MockAssetMediaRepository(); diff --git a/mobile/test/services/view_intent_asset_resolver_test.dart b/mobile/test/services/view_intent_asset_resolver_test.dart index 38d2f71f88..36c343180a 100644 --- a/mobile/test/services/view_intent_asset_resolver_test.dart +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -16,13 +16,13 @@ import '../infrastructure/repository.mock.dart'; class MockTimelineFactory extends Mock implements TimelineFactory {} void main() { - late MockDriftLocalAssetRepository mockLocalAssetRepository; + late MockLocalAssetRepository mockLocalAssetRepository; late MockTimelineFactory timelineFactory; late List createdTimelineServices; late ProviderContainer container; setUp(() { - mockLocalAssetRepository = MockDriftLocalAssetRepository(); + mockLocalAssetRepository = MockLocalAssetRepository(); timelineFactory = MockTimelineFactory(); createdTimelineServices = []; @@ -33,9 +33,12 @@ void main() { return timelineService; }); + final drift = MockDrift(); + when(() => drift.localAssetRepository).thenReturn(mockLocalAssetRepository); + container = ProviderContainer( overrides: [ - localAssetRepository.overrideWith((ref) => mockLocalAssetRepository), + driftProvider.overrideWithValue(drift), timelineFactoryProvider.overrideWith((ref) => timelineFactory), ], ); diff --git a/mobile/test/test_utils/medium_factory.dart b/mobile/test/test_utils/medium_factory.dart index c8c41bbf0f..fa40de0160 100644 --- a/mobile/test/test_utils/medium_factory.dart +++ b/mobile/test/test_utils/medium_factory.dart @@ -31,8 +31,8 @@ class MediumFactory { T getRepository() { switch (T) { - case const (DriftLocalAlbumRepository): - return DriftLocalAlbumRepository(_db) as T; + case const (LocalAlbumRepository): + return LocalAlbumRepository(_db) as T; default: throw Exception('Unknown repository: $T'); } diff --git a/mobile/test/unit/factories/local_album_factory.dart b/mobile/test/unit/factories/local_album_factory.dart index 447001f971..639237c858 100644 --- a/mobile/test/unit/factories/local_album_factory.dart +++ b/mobile/test/unit/factories/local_album_factory.dart @@ -14,10 +14,10 @@ class LocalAlbumFactory { String? linkedRemoteAlbumId, int? assetCount, }) { - id = TestUtils.uuid(id); + final albumId = TestUtils.uuid(id); return LocalAlbum( - id: id, - name: name ?? 'local_album_$id', + id: albumId, + name: name ?? 'local_album_$albumId', updatedAt: TestUtils.date(updatedAt), backupSelection: backupSelection ?? .none, isIosSharedAlbum: isIosSharedAlbum ?? false, diff --git a/mobile/test/unit/factories/local_asset_factory.dart b/mobile/test/unit/factories/local_asset_factory.dart index bebe24e5b9..5b16635d48 100644 --- a/mobile/test/unit/factories/local_asset_factory.dart +++ b/mobile/test/unit/factories/local_asset_factory.dart @@ -6,11 +6,11 @@ class LocalAssetFactory { const LocalAssetFactory(); static LocalAsset create({String? id, String? name, String? remoteId}) { - id = TestUtils.uuid(id); + final assetId = TestUtils.uuid(id); return LocalAsset( - id: id, - name: name ?? 'local_$id.jpg', + id: assetId, + name: name ?? 'local_$assetId.jpg', remoteId: remoteId, type: AssetType.image, createdAt: TestUtils.yesterday(), diff --git a/mobile/test/unit/factories/partner_user_factory.dart b/mobile/test/unit/factories/partner_user_factory.dart index 63f94608ad..2ee0fa0773 100644 --- a/mobile/test/unit/factories/partner_user_factory.dart +++ b/mobile/test/unit/factories/partner_user_factory.dart @@ -6,11 +6,11 @@ class PartnerFactory { const PartnerFactory(); static Partner create({String? id, String? email, String? name, bool? inTimeline}) { - id = TestUtils.uuid(id); + final partnerId = TestUtils.uuid(id); return Partner( - id: id, - email: email ?? '$id@test.com', - name: name ?? 'user_$id', + id: partnerId, + email: email ?? '$partnerId@test.com', + name: name ?? 'user_$partnerId', inTimeline: inTimeline ?? false, hasProfileImage: false, profileChangedAt: DateTime.now(), diff --git a/mobile/test/unit/factories/remote_album_factory.dart b/mobile/test/unit/factories/remote_album_factory.dart index bffe62b651..70976b4485 100644 --- a/mobile/test/unit/factories/remote_album_factory.dart +++ b/mobile/test/unit/factories/remote_album_factory.dart @@ -19,10 +19,10 @@ class RemoteAlbumFactory { String? ownerName, bool isShared = false, }) { - id = TestUtils.uuid(id); + final albumId = TestUtils.uuid(id); return RemoteAlbum( - id: id, - name: name ?? 'remote_album_$id', + id: albumId, + name: name ?? 'remote_album_$albumId', ownerId: TestUtils.uuid(ownerId), description: description ?? '', createdAt: TestUtils.date(createdAt), @@ -31,7 +31,7 @@ class RemoteAlbumFactory { isActivityEnabled: isActivityEnabled, order: order, assetCount: assetCount, - ownerName: ownerName ?? 'owner_$id', + ownerName: ownerName ?? 'owner_$albumId', isShared: isShared, ); } diff --git a/mobile/test/unit/factories/remote_asset_factory.dart b/mobile/test/unit/factories/remote_asset_factory.dart index 0876b9d793..2936708ab4 100644 --- a/mobile/test/unit/factories/remote_asset_factory.dart +++ b/mobile/test/unit/factories/remote_asset_factory.dart @@ -16,13 +16,13 @@ class RemoteAssetFactory { DateTime? deletedAt, String? localId, }) { - id = TestUtils.uuid(id); + final assetId = TestUtils.uuid(id); return RemoteAsset( - id: id, - name: name ?? 'remote_$id.jpg', + id: assetId, + name: name ?? 'remote_$assetId.jpg', ownerId: TestUtils.uuid(ownerId), - checksum: 'checksum-$id', + checksum: 'checksum-$assetId', type: type, createdAt: TestUtils.yesterday(), updatedAt: TestUtils.now(), diff --git a/mobile/test/unit/factories/user_factory.dart b/mobile/test/unit/factories/user_factory.dart index 248e98dc69..d3df9556ef 100644 --- a/mobile/test/unit/factories/user_factory.dart +++ b/mobile/test/unit/factories/user_factory.dart @@ -13,11 +13,11 @@ class UserFactory { bool? hasProfileImage, AvatarColor? avatarColor, }) { - id = TestUtils.uuid(id); + final userId = TestUtils.uuid(id); return User( - id: id, - name: name ?? 'user_$id', - email: email ?? '$id@test.com', + id: userId, + name: name ?? 'user_$userId', + email: email ?? '$userId@test.com', profileChangedAt: TestUtils.date(profileChangedAt), hasProfileImage: hasProfileImage ?? false, avatarColor: avatarColor ?? .primary, @@ -32,11 +32,11 @@ class UserFactory { bool? hasProfileImage, AvatarColor? avatarColor, }) { - id = TestUtils.uuid(id); + final userId = TestUtils.uuid(id); return UserDto( - id: id, - name: name ?? 'user_$id', - email: email ?? '$id@test.com', + id: userId, + name: name ?? 'user_$userId', + email: email ?? '$userId@test.com', profileChangedAt: TestUtils.date(profileChangedAt), hasProfileImage: hasProfileImage ?? false, avatarColor: avatarColor ?? .primary, diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 1d43198263..754c20aa48 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -26,12 +26,13 @@ import 'factories/user_factory.dart'; class RepositoryMocks { final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository()); - final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository()); + final localAsset = LocalAssetRepositoryStub(MockLocalAssetRepository()); final remoteAsset = RemoteAssetRepositoryStub(MockRemoteAssetRepository()); final remoteExif = RemoteExifRepositoryStub(MockRemoteExifRepository()); final trashedAsset = MockTrashedLocalAssetRepository(); final remoteAlbum = MockRemoteAlbumRepository(); final albumApi = MockDriftAlbumApiRepository(); + final permission = PermissionRepositoryStub(MockPermissionRepository()); final nativeApi = NativeSyncApiStub(MockNativeSyncApi()); final assetApi = AssetApiRepositoryStub(MockAssetApiRepository()); @@ -55,6 +56,7 @@ class RepositoryMocks { assetApi.reset(); assetMedia.reset(); download.reset(); + permission.reset(); _stubLocalAlbumRepository(); _stubLocalAssetRepository(); _stubRemoteAssetRepository(); @@ -63,6 +65,7 @@ class RepositoryMocks { _stubAssetApiRepository(); _stubAssetMediaRepository(); _stubDownloadRepository(); + _stubPermissionRepository(); } void _stubRemoteAssetRepository() { @@ -101,6 +104,12 @@ class RepositoryMocks { void _stubDownloadRepository() { when(download.downloadAllAssets).thenAnswer((_) async => const []); } + + void _stubPermissionRepository() { + when(permission.getStatus).thenAnswer((_) async => DevicePermissionStatus.denied); + when(permission.request).thenAnswer((_) async => DevicePermissionStatus.denied); + when(permission.getAndroidSdkVersion).thenAnswer((_) async => 34); + } } class ServiceMocks { @@ -220,6 +229,8 @@ void _registerFallbacks() { registerFallbackValue(ShareAssetType.original); registerFallbackValue(const UploadCallbacks()); registerFallbackValue(_FakeBuildContext()); + registerFallbackValue(DevicePermissionStatus.granted); + registerFallbackValue(DevicePermission.photos); } class _FakeBuildContext extends Fake implements BuildContext {} @@ -236,8 +247,8 @@ extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) imp () => repo.getAssetsToHash(any()); } -extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo) - implements Stub { +extension type const LocalAssetRepositoryStub(MockLocalAssetRepository repo) + implements Stub { Future Function() get reconcileHashesFromCloudId => () => repo.reconcileHashesFromCloudId(); @@ -254,7 +265,7 @@ extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo) () => repo.getAssetEdits(any()); Future Function() get update => - () => repo.update( + () => repo.updateAssets( any(), isFavorite: any(named: 'isFavorite'), visibility: any(named: 'visibility'), @@ -264,7 +275,7 @@ extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo) extension type const RemoteExifRepositoryStub(MockRemoteExifRepository repo) implements Stub { Future Function() get update => - () => repo.update( + () => repo.updateExif( any(), dateTimeOriginal: any(named: 'dateTimeOriginal'), timeZone: any(named: 'timeZone'), @@ -400,6 +411,17 @@ extension type const DownloadRepositoryStub(MockDownloadRepository repo) impleme () => repo.downloadAllAssets(any()); } +extension type const PermissionRepositoryStub(MockPermissionRepository repo) implements Stub { + Future Function() get getStatus => + () => repo.getStatus(any()); + + Future Function() get request => + () => repo.request(any()); + + Future Function() get getAndroidSdkVersion => + () => repo.getAndroidSdkVersion(); +} + extension type const TagServiceStub(MockTagService service) implements Stub { Future Function() get bulkTagAssets => () => service.bulkTagAssets(any(), any()); diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart index 29d81a5c2c..4c1bf9306c 100644 --- a/mobile/test/unit/presentation/actions/delete_action_test.dart +++ b/mobile/test/unit/presentation/actions/delete_action_test.dart @@ -170,6 +170,17 @@ void main() { verifyNever(() => assetService.trash(any())); verifyNever(() => assetService.delete(any())); }); + + testWidgets('is labelled trash', (tester) async { + await tester.pumpTestWidget( + context, + const ActionButton(action: DeleteAction(source: .timeline)), + overrides: context.selected({LocalAssetFactory.create()}), + ); + + expect(find.text(StaticTranslations.instance.trash), findsOneWidget); + expect(find.text(StaticTranslations.instance.delete), findsNothing); + }); }); group('prompt handling', () { diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 36ae8d087d..1d46260435 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -17,6 +17,7 @@ import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -28,6 +29,7 @@ import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_ui/immich_ui.dart'; import 'package:mocktail/mocktail.dart'; +import '../../infrastructure/repository.mock.dart'; import '../../test_utils.dart'; import '../factories/user_factory.dart'; import '../mocks.dart'; @@ -49,6 +51,7 @@ class PresentationContext { final RepositoryMocks repository; List get overrides => [ + driftProvider.overrideWithValue(_mockDrift()), currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)), assetServiceProvider.overrideWithValue(service.asset.service), cleanupServiceProvider.overrideWithValue(service.cleanup.service), @@ -57,10 +60,15 @@ class PresentationContext { gCastServiceProvider.overrideWithValue(service.cast), serverInfoServiceProvider.overrideWithValue(service.serverInfo), inLockedViewProvider.overrideWithValue(false), - remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo), assetMediaRepositoryProvider.overrideWithValue(repository.assetMedia.api), ]; + Drift _mockDrift() { + final drift = MockDrift(); + when(() => drift.remoteAssetRepository).thenReturn(repository.remoteAsset.repo); + return drift; + } + List selected(Set assets) => [ multiSelectProvider.overrideWith( () => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})), @@ -71,7 +79,7 @@ class PresentationContext { TestUtils.init(); if (_db == null) { final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false); + await StoreService.init(storeRepository: StoreRepository(db), listenUpdates: false); await StoreService.I.put(StoreKey.serverEndpoint, serverEndpoint); _db = db; } diff --git a/mobile/test/unit/services/asset_service_test.dart b/mobile/test/unit/services/asset_service_test.dart index a09465701b..b857be1fcb 100644 --- a/mobile/test/unit/services/asset_service_test.dart +++ b/mobile/test/unit/services/asset_service_test.dart @@ -22,7 +22,7 @@ void main() { sut = AssetService( remoteRepository: remoteRepository, exifRepository: exifRepository, - localRepository: MockDriftLocalAssetRepository(), + localRepository: MockLocalAssetRepository(), apiRepository: apiRepository, mediaRepository: mocks.assetMedia.api, trashedLocalRepository: mocks.trashedAsset, @@ -37,9 +37,9 @@ void main() { await sut.update(ids, dateTime: const .some(picked)); verify(() => apiRepository.update(ids, dateTimeOriginal: const .some(picked))).called(1); - verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1); + verify(() => remoteRepository.updateAssets(ids, createdAt: .some(DateTime.parse(picked)))).called(1); verify( - () => exifRepository.update( + () => exifRepository.updateExif( ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .some('UTC+06:00'), @@ -51,9 +51,9 @@ void main() { const picked = '2026-01-05T08:00:00.000-05:30'; await sut.update(ids, dateTime: const .some(picked)); - verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1); + verify(() => remoteRepository.updateAssets(ids, createdAt: .some(DateTime.parse(picked)))).called(1); verify( - () => exifRepository.update( + () => exifRepository.updateExif( ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .some('UTC-05:30'), @@ -65,9 +65,9 @@ void main() { const picked = '2026-06-10T13:15:00.000Z'; await sut.update(ids, dateTime: const .some(picked)); - verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1); + verify(() => remoteRepository.updateAssets(ids, createdAt: .some(DateTime.parse(picked)))).called(1); verify( - () => exifRepository.update(ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .none()), + () => exifRepository.updateExif(ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .none()), ).called(1); }); diff --git a/mobile/test/unit/services/gallery_permission_service_test.dart b/mobile/test/unit/services/gallery_permission_service_test.dart new file mode 100644 index 0000000000..9a399f9948 --- /dev/null +++ b/mobile/test/unit/services/gallery_permission_service_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/services/device_permission.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../mocks.dart'; + +void main() { + late RepositoryMocks mocks; + late DevicePermissionService sut; + + void stubPermissions(Map statuses) { + DevicePermissionStatus answer(Invocation i) => statuses[i.positionalArguments.first] ?? .denied; + when(mocks.permission.getStatus).thenAnswer((i) async => answer(i)); + when(mocks.permission.request).thenAnswer((i) async => answer(i)); + } + + setUp(() { + debugDefaultTargetPlatformOverride = .android; + mocks = .new(); + sut = .new(mocks.permission.repo); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + group('DevicePermissionService', () { + group('Gallery', () { + test('is denied when media location is missing but photos and videos are granted', () async { + when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 34); + stubPermissions({.photos: .granted, .videos: .granted, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1); + }); + + test('is granted when media location is granted', () async { + when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 34); + stubPermissions({.photos: .granted, .videos: .granted, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.granted); + verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1); + }); + + test('checks media location on the legacy storage path', () async { + when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 30); + stubPermissions({.storage: .granted, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1); + }); + + test('ignores media location below SDK 29', () async { + when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 28); + stubPermissions({.storage: .granted, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.granted); + verifyNever(() => mocks.permission.repo.getStatus(.mediaLocation)); + }); + + test('ignores media location on iOS', () async { + debugDefaultTargetPlatformOverride = .iOS; + stubPermissions({.photos: .granted, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.granted); + verifyNever(() => mocks.permission.repo.getStatus(.mediaLocation)); + }); + + test('is limited when only one of photos and videos is limited', () async { + stubPermissions({.photos: .granted, .videos: .limited, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.limited); + + stubPermissions({.photos: .limited, .videos: .granted, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.limited); + + stubPermissions({.photos: .limited, .videos: .limited, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.limited); + }); + + test('is denied when only one of photos and videos is denied', () async { + stubPermissions({.photos: .granted, .videos: .denied, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + + stubPermissions({.photos: .denied, .videos: .granted, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + + stubPermissions({.photos: .limited, .videos: .denied, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + }); + + test('is permanently denied when only one of photos and videos is permanently denied', () async { + stubPermissions({.photos: .granted, .videos: .permanentlyDenied, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.permanentlyDenied); + + stubPermissions({.photos: .permanentlyDenied, .videos: .denied, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.permanentlyDenied); + + stubPermissions({.photos: .permanentlyDenied, .videos: .granted, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.permanentlyDenied); + }); + + test('checks media location when access is only partial', () async { + stubPermissions({.photos: .limited, .videos: .limited, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1); + }); + + test('stays limited when media location is granted, but photos or video is limited', () async { + stubPermissions({.photos: .granted, .videos: .limited, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.limited); + }); + }); + + test('does not ask for videos when photos was refused', () async { + stubPermissions({.photos: .permanentlyDenied}); + + expect(await sut.requestGallery(), DevicePermissionStatus.permanentlyDenied); + verifyNever(() => mocks.permission.repo.request(.videos)); + }); + }); +} diff --git a/mobile/test/utils/image_url_builder_test.dart b/mobile/test/utils/image_url_builder_test.dart index 1845d38eeb..055af185f7 100644 --- a/mobile/test/utils/image_url_builder_test.dart +++ b/mobile/test/utils/image_url_builder_test.dart @@ -12,7 +12,7 @@ void main() { setUpAll(() async { final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false); + await StoreService.init(storeRepository: StoreRepository(db), listenUpdates: false); await StoreService.I.put(StoreKey.serverEndpoint, endpoint); }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bc3bf82094..d00faab9a0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -15873,6 +15873,15 @@ "type": "string" } }, + { + "name": "logging", + "required": false, + "in": "query", + "description": "Workflow logs run results", + "schema": { + "type": "boolean" + } + }, { "name": "name", "required": false, @@ -16186,6 +16195,95 @@ "x-immich-state": "Deprecated" } }, + "/workflows/{id}/logs": { + "get": { + "description": "Retrieve logs of a workflows runs by ID", + "operationId": "getWorkflowLogs", + "parameters": [ + { + "name": "before", + "required": false, + "in": "query", + "description": "Filter by runs before a date/time", + "schema": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "example": "2024-01-01T00:00:00.000Z", + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Maximum number of logs", + "schema": { + "maximum": 9007199254740991, + "exclusiveMinimum": true, + "default": 50, + "type": "integer", + "minimum": 0 + } + }, + { + "name": "result", + "required": false, + "in": "query", + "description": "Filter by run result", + "schema": { + "$ref": "#/components/schemas/WorkflowResult" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowLogEntryDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve workflow logs", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ], + "x-immich-permission": "workflow.logs" + } + }, "/workflows/{id}/share": { "get": { "description": "Retrieve a workflow details without ids, default values, etc.", @@ -21057,6 +21155,7 @@ "workflow.read", "workflow.update", "workflow.delete", + "workflow.logs", "adminUser.create", "adminUser.read", "adminUser.update", @@ -27909,6 +28008,10 @@ "description": "Workflow enabled", "type": "boolean" }, + "logging": { + "description": "Workflow logs run results", + "type": "boolean" + }, "name": { "description": "Workflow name", "nullable": true, @@ -27930,6 +28033,59 @@ ], "type": "object" }, + "WorkflowLogEntryDto": { + "properties": { + "at": { + "description": "Workflow run date/time", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "Workflow log entry ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "lastStep": { + "description": "Last step ran, if the workflow ended early", + "properties": { + "index": { + "description": "Index of the step in the workflow", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "method": { + "description": "Method of the step", + "type": "string" + } + }, + "required": [ + "method", + "index" + ], + "type": "object" + }, + "result": { + "$ref": "#/components/schemas/WorkflowResult" + }, + "triggerDataId": { + "description": "Workflow trigger data ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + "required": [ + "at", + "id", + "result" + ], + "type": "object" + }, "WorkflowResponseDto": { "properties": { "createdAt": { @@ -27951,6 +28107,10 @@ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, + "logging": { + "description": "Workflow logs run results", + "type": "boolean" + }, "name": { "description": "Workflow name", "nullable": true, @@ -27977,6 +28137,7 @@ "description", "enabled", "id", + "logging", "name", "steps", "trigger", @@ -27984,6 +28145,15 @@ ], "type": "object" }, + "WorkflowResult": { + "description": "Workflow run result", + "enum": [ + "completed", + "halted", + "error" + ], + "type": "string" + }, "WorkflowShareResponseDto": { "properties": { "description": { @@ -28066,7 +28236,8 @@ "description": "Plugin trigger type", "enum": [ "AssetCreate", - "AssetMetadataExtraction" + "AssetMetadataExtraction", + "AssetTagged" ], "type": "string" }, @@ -28108,6 +28279,10 @@ "description": "Workflow enabled", "type": "boolean" }, + "logging": { + "description": "Workflow logs run results", + "type": "boolean" + }, "name": { "description": "Workflow name", "nullable": true, diff --git a/open-api/patch/pubspec_immich_mobile.yaml.patch b/open-api/patch/pubspec_immich_mobile.yaml.patch index ad3bbc2edd..be210f864f 100644 --- a/open-api/patch/pubspec_immich_mobile.yaml.patch +++ b/open-api/patch/pubspec_immich_mobile.yaml.patch @@ -6,4 +6,4 @@ -dev_dependencies: - test: '>=1.21.6 <1.22.0' + immich_mobile: -+ path: ../ ++ path: ../../ diff --git a/package.json b/package.json index 2464012cee..75222c8220 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "format": "prettier --cache --check i18n/", "format:fix": "prettier --cache --write --list-different i18n" }, - "packageManager": "pnpm@11.17.0", + "packageManager": "pnpm@11.20.0", "engines": { "pnpm": ">=10.0.0" }, diff --git a/packages/cli/Dockerfile b/packages/cli/Dockerfile index 716522ecd7..c1c320fc11 100644 --- a/packages/cli/Dockerfile +++ b/packages/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS core +FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d AS core WORKDIR /usr/src/app COPY package* pnpm* .pnpmfile.cjs ./ diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index bcc74c654d..cd32bf6023 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/packages/e2e-auth-server/Dockerfile b/packages/e2e-auth-server/Dockerfile index bfc939c084..087fa45d23 100644 --- a/packages/e2e-auth-server/Dockerfile +++ b/packages/e2e-auth-server/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 +FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d WORKDIR /usr/src/app COPY package* pnpm* .pnpmfile.cjs ./ COPY ./packages ./packages/ diff --git a/packages/e2e-auth-server/package.json b/packages/e2e-auth-server/package.json index b872dfe36d..eae4fd3e04 100644 --- a/packages/e2e-auth-server/package.json +++ b/packages/e2e-auth-server/package.json @@ -13,5 +13,5 @@ "oidc-provider": "^9.0.0", "tsx": "^4.20.6" }, - "packageManager": "pnpm@11.17.0" + "packageManager": "pnpm@11.20.0" } diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index a72d60c04d..d7f7830a0e 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -308,6 +308,56 @@ }, "uiHints": ["Filter"] }, + { + "name": "assetAddTags", + "title": "Add Tags", + "description": "Add tags to an asset", + "types": ["AssetV1"], + "hostFunctions": true, + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Tags to add to the asset", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + } + }, + "required": ["tags"] + } + }, + { + "name": "assetTagFilter", + "title": "Filter by tags", + "description": "Filter assets by which tags they have", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Which tags the asset must have", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + }, + "matching": { + "title": "Matching", + "description": "Whether assets must have all, any, or none of the listed tags", + "type": "string", + "enum": ["all", "any", "none"] + } + }, + "required": ["tags", "matching"] + }, + "uiHints": ["Filter"] + }, { "name": "assetExifFilter", "title": "Filter by EXIF metadata", diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 7b91ed7111..251393ace5 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -39,6 +39,11 @@ const matchValueResult = (value: string, config: MatchValueConfig) => { }; const methods = wrapper({ + assetAddTags: ({ config, data, functions }) => { + functions.bulkTagAssets({ assetIds: [data.asset.id], tagIds: config.tags }); + return {}; + }, + assetAddToAlbums: ({ config, data, functions }) => { const assetId = data.asset.id; @@ -176,6 +181,24 @@ const methods = wrapper({ return { workflow: { continue: hasTimeZone === needsTimeZone } }; }, + assetTagFilter: ({ config, data }) => { + const assetTags = data.asset.tags.map((tag) => tag.id); + + for (const tag of config.tags) { + if (assetTags.includes(tag)) { + if (config.matching === 'any') { + break; + } else if (config.matching === 'none') { + return { workflow: { continue: false } }; + } + } else if (config.matching === 'all') { + return { workflow: { continue: false } }; + } + } + + return { workflow: { continue: true } }; + }, + assetTypeFilter: ({ config, data }) => { return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; }, @@ -208,6 +231,7 @@ const methods = wrapper({ }); const { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -217,6 +241,7 @@ const { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, @@ -226,6 +251,7 @@ const { } = methods; export { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -235,6 +261,7 @@ export { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 8afa00cfb4..4ae3ca76a7 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -27,7 +27,7 @@ "keywords": [], "author": "", "license": "GNU Affero General Public License version 3", - "packageManager": "pnpm@11.17.0", + "packageManager": "pnpm@11.20.0", "devDependencies": { "@extism/js-pdk": "^1.1.1", "@immich/sdk": "workspace:*", diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts index 9d52630438..e0b6e34da9 100644 --- a/packages/plugin-sdk/src/host-functions.ts +++ b/packages/plugin-sdk/src/host-functions.ts @@ -4,6 +4,8 @@ import { type BulkIdResponseDto, type BulkIdsDto, type CreateAlbumDto, + type TagBulkAssetsDto, + type TagBulkAssetsResponseDto, } from '@immich/sdk'; declare module 'extism:host' { @@ -47,6 +49,7 @@ export const availableFunctions = [ 'addAssetsToAlbum', 'addAssetsToAlbums', 'httpRequest', + 'bulkTagAssets', ] as const; export const hostFunctions = (authToken: string) => { @@ -96,5 +99,11 @@ export const hostFunctions = (authToken: string) => { authToken, [url, options], ), + bulkTagAssets: (dto: TagBulkAssetsDto) => + call<[TagBulkAssetsDto], TagBulkAssetsResponseDto>( + 'bulkTagAssets', + authToken, + [dto], + ), } satisfies Record<(typeof availableFunctions)[number], unknown>; }; diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 9756d652c3..94c968978d 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -1,4 +1,9 @@ -import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk'; +import type { + AssetTypeEnum, + AssetVisibility, + TagResponseDto, + WorkflowType, +} from '@immich/sdk'; type DeepPartial = T extends Date ? T @@ -18,6 +23,7 @@ export type WorkflowEventData = WorkflowEventMap[T]; export enum WorkflowTrigger { AssetCreate = 'AssetCreate', AssetMetadataExtraction = 'AssetMetadataExtraction', + AssetTagged = 'AssetTagged', // PersonRecognized = 'PersonRecognized', } @@ -88,6 +94,7 @@ export type AssetV1 = { duplicateId: string | null; visibility: AssetVisibility; isEdited: boolean; + tags: TagResponseDto[]; exifInfo: { make: string | null; model: string | null; diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 66327e6150..ca08f10759 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -32,5 +32,5 @@ "vite": "^8.0.16", "vitest": "^4.1.8" }, - "packageManager": "pnpm@11.17.0" + "packageManager": "pnpm@11.20.0" } diff --git a/packages/scripts/src/cli.ts b/packages/scripts/src/cli.ts index c89a0f142c..95e50e9770 100644 --- a/packages/scripts/src/cli.ts +++ b/packages/scripts/src/cli.ts @@ -23,32 +23,25 @@ export const cli = (argv: string[]) => { .choices(RELEASE_TYPES) .makeOptionMandatory(), ) - .addOption( - new Option('-m, --mobile ', 'pump mobile build number') - .choices(['true', 'false']) - .default('false'), - ) - .action( - ({ type, mobile }: { type: ReleaseType; mobile: 'true' | 'false' }) => { - try { - console.log(handleRelease({ type, mobile: mobile === 'true' })); - } catch (error) { - if (error instanceof ReleaseInputError) { - console.log(program.usage()); - process.exit(1); - } - - if (error instanceof ReleaseError) { - console.log( - `Invalid pump: ${type}. Pumping from ${error.version} to ${error.newVersion} is not allowed.`, - ); - process.exit(1); - } - - throw error; + .action(({ type }: { type: ReleaseType }) => { + try { + console.log(handleRelease({ type })); + } catch (error) { + if (error instanceof ReleaseInputError) { + console.log(program.usage()); + process.exit(1); } - }, - ); + + if (error instanceof ReleaseError) { + console.log( + `Invalid pump: ${type}. Pumping from ${error.version} to ${error.newVersion} is not allowed.`, + ); + process.exit(1); + } + + throw error; + } + }); return program.parse(argv); }; diff --git a/packages/scripts/src/commands/release.spec.ts b/packages/scripts/src/commands/release.spec.ts index de769ebdc6..dc3f82517d 100644 --- a/packages/scripts/src/commands/release.spec.ts +++ b/packages/scripts/src/commands/release.spec.ts @@ -1,17 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - resolveArchivedVersions, - ArchivedVersion, - getNewVersion, - ReleaseError, -} from './release'; +import semver from 'semver'; +import { describe, expect, it } from 'vitest'; +import { getMobileBuild, getNewVersion, ReleaseError } from './release'; -const archived = (label: string): ArchivedVersion => ({ - label, - url: `https://docs.${label}.archive.immich.app`, -}); - -const labels = (versions: ArchivedVersion[]) => versions.map((v) => v.label); +const mobileBuild = (version: string) => + getMobileBuild(semver.parse(version) as semver.SemVer); describe(getNewVersion.name, () => { describe('transitions', () => { @@ -77,63 +69,55 @@ describe(getNewVersion.name, () => { }); }); -describe(resolveArchivedVersions.name, () => { - beforeEach(() => { - // silence the "Removed ..." progress logging - vi.spyOn(console, 'log').mockImplementation(() => {}); +describe(getMobileBuild.name, () => { + it('should encode the version', () => { + expect(mobileBuild('3.1.0')).toBe(3_010_099); + expect(mobileBuild('3.1.4')).toBe(3_010_499); + expect(mobileBuild('3.2.0-rc.0')).toBe(3_020_000); + expect(mobileBuild('3.2.0-rc.3')).toBe(3_020_003); }); - it('should prepend the new version to the front', () => { - const result = resolveArchivedVersions([archived('v2.9.0')], '3.0.0'); - - expect(result[0]).toEqual({ - label: 'v3.0.0', - url: 'https://docs.v3.0.0.archive.immich.app', - }); - expect(labels(result)).toEqual(['v3.0.0', 'v2.9.0']); + it('should be above the last hand-maintained build number', () => { + expect(mobileBuild('3.1.0')).toBeGreaterThan(3057); }); - it('should handle an empty list', () => { - expect(labels(resolveArchivedVersions([], '3.0.0'))).toEqual(['v3.0.0']); + it('should stay under the play store limit', () => { + expect(mobileBuild('99.99.99')).toBeLessThan(2_100_000_000); }); - it('should drop older patch releases of the new version minor', () => { - const versions = ['v3.0.4', 'v3.0.3', 'v3.0.2'].map(archived); + it('should increase across a release cycle', () => { + const versions = [ + '3.1.0', + '3.1.1', + '3.2.0-rc.0', + '3.2.0-rc.1', + '3.2.0', + '3.2.1', + '3.3.0-rc.0', + '4.0.0-rc.0', + '4.0.0', + ]; - expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([ - 'v3.0.5', - ]); + const builds = versions.map((version) => mobileBuild(version)); + expect(builds).toEqual([...builds].sort((a, b) => a - b)); + expect(new Set(builds).size).toBe(builds.length); }); - it('should keep the latest patch of each older minor', () => { - const versions = ['v3.1.2', 'v3.1.1', 'v3.0.9', 'v2.0.0'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.2.0'))).toEqual([ - 'v3.2.0', - 'v3.1.2', - 'v3.0.9', - 'v2.0.0', - ]); + it('should rank a release above its own candidates', () => { + expect(mobileBuild('3.2.0')).toBeGreaterThan(mobileBuild('3.2.0-rc.98')); }); - it('should keep an older minor when bumping a patch', () => { - const versions = ['v3.0.4', 'v3.0.3', 'v2.9.1', 'v2.9.0', 'v1.5.0'].map( - archived, - ); - - expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([ - 'v3.0.5', - 'v2.9.1', - 'v1.5.0', - ]); + it('should rank a patch on an older line below the newer line', () => { + expect(mobileBuild('3.1.5')).toBeLessThan(mobileBuild('3.2.0')); }); - it('should replace a prerelease with its release', () => { - const versions = ['v3.0.0-rc.2', 'v3.0.0-rc.1', 'v2.9.0'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.0.0'))).toEqual([ - 'v3.0.0', - 'v2.9.0', - ]); + it('should refuse versions it cannot encode', () => { + expect(() => mobileBuild('100.0.0')).toThrow('Cannot derive'); + expect(() => mobileBuild('3.100.0')).toThrow('Cannot derive'); + expect(() => mobileBuild('3.1.100')).toThrow('Cannot derive'); + expect(() => mobileBuild('3.1.0-rc.99')).toThrow('Cannot derive'); + expect(() => mobileBuild('3.1.0-beta')).toThrow('Cannot derive'); + expect(() => mobileBuild('3.1.0-rc.-1')).toThrow('Cannot derive'); + expect(() => mobileBuild('3.1.0-rc.x')).toThrow('Cannot derive'); }); }); diff --git a/packages/scripts/src/commands/release.ts b/packages/scripts/src/commands/release.ts index 68ef1dbf0e..2c0d82d5f6 100644 --- a/packages/scripts/src/commands/release.ts +++ b/packages/scripts/src/commands/release.ts @@ -26,7 +26,6 @@ const Files = { Docs: { Env: join(root, 'docs/docs/install/environment-variables.md'), Upgrading: join(root, 'docs/docs/install/upgrading.md'), - ArchivedVersions: join(root, 'docs/static/archived-versions.json'), }, Mobile: { Pubspec: join(root, 'mobile/pubspec.yaml'), @@ -35,7 +34,7 @@ const Files = { }, }; -export const handleRelease = ({ type, mobile }: ReleaseOptions) => { +export const handleRelease = ({ type }: ReleaseOptions) => { const versionRaw = getVersion(); const newVersionRaw = getNewVersion(versionRaw, type); const newVersion = semver.parse(normalize(newVersionRaw)); @@ -43,8 +42,7 @@ export const handleRelease = ({ type, mobile }: ReleaseOptions) => { throw new ReleaseInputError(); } const newVersionNoRc = `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`; - const mobileBuild = getMobileBuild(); - const newMobileBuild = mobile ? mobileBuild + 1 : mobileBuild; + const newMobileBuild = getMobileBuild(newVersion); // pump versions everywhere @@ -93,13 +91,6 @@ export const handleRelease = ({ type, mobile }: ReleaseOptions) => { pump(Files.Docs.Upgrading, /:v\d+/, `:${major}`); } - // update archived versions list - const archivedFile = new JsonFile( - Files.Docs.ArchivedVersions, - ); - const versions = archivedFile.read(); - archivedFile.write(resolveArchivedVersions(versions, newVersionRaw)); - if (process.env.GITHUB_ENV) { // make available for following steps appendFileSync( @@ -189,14 +180,32 @@ export const getNewVersion = (versionRaw: string, type: string) => { return newVersionRaw; }; -const getMobileBuild = () => { - const pubspec = new TextFile(Files.Mobile.Pubspec).read(); - const match = pubspec.match(/^version: .*\+(\d+)$/m); - if (!match) { - throw new Error('Could not find mobile build number in pubspec.yaml'); +const RADIX = 100; +const STABLE = RADIX - 1; + +export const getMobileBuild = (version: SemVer) => { + const { major, minor, patch, prerelease } = version; + const candidate = prerelease[1]; + const digit = typeof candidate === 'number' ? candidate : STABLE; + + const valid = + major < RADIX && + minor < RADIX && + patch < RADIX && + (prerelease.length === 0 || digit < STABLE); + + if (!valid) { + throw new Error( + `Cannot derive a mobile build number from ${version.format()}`, + ); } - return Number(match[1]); + return ( + major * Math.pow(RADIX, 3) + + minor * Math.pow(RADIX, 2) + + patch * RADIX + + digit + ); }; const pump = (path: string, pattern: RegExp, replacement: string) => { @@ -205,50 +214,6 @@ const pump = (path: string, pattern: RegExp, replacement: string) => { file.write(update); }; -export interface ArchivedVersion { - label: string; - url: string; -} - -export const resolveArchivedVersions = ( - versions: ArchivedVersion[], - nextVersion: string, -): ArchivedVersion[] => { - const newVersion: ArchivedVersion = { - label: `v${nextVersion}`, - url: `https://docs.v${nextVersion}.archive.immich.app`, - }; - - let result = versions; - let lastVersion = asVersion(newVersion); - for (const item of versions) { - const version = asVersion(item); - // only keep the latest patch version for each minor release - if ( - lastVersion.major === version.major && - lastVersion.minor === version.minor && - lastVersion.patch >= version.patch - ) { - result = result.filter((item) => item.label !== version.label); - console.log( - `Removed ${version.label} (replaced with ${lastVersion.label})`, - ); - continue; - } - - lastVersion = version; - } - - return [newVersion, ...result]; -}; - -const asVersion = (item: ArchivedVersion) => { - const { label, url } = item; - const [version] = label.substring(1).split('-'); - const [major, minor, patch] = version.split('.').map(Number); - return { major, minor, patch, label, url }; -}; - const isPrerelease = (version: SemVer) => version.prerelease.length > 0; /** diff --git a/packages/scripts/src/types.ts b/packages/scripts/src/types.ts index 9bc23f87b5..586fdb8763 100644 --- a/packages/scripts/src/types.ts +++ b/packages/scripts/src/types.ts @@ -11,7 +11,7 @@ export const RELEASE_TYPES = [ ] as const; export type ReleaseType = (typeof RELEASE_TYPES)[number]; -export type ReleaseOptions = { type: string; mobile: boolean }; +export type ReleaseOptions = { type: string }; export class TextFile { constructor(private file: string) {} diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3ac958ce2d..b2f98b58ef 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2791,6 +2791,8 @@ export type WorkflowResponseDto = { enabled: boolean; /** Workflow ID */ id: string; + /** Workflow logs run results */ + logging: boolean; /** Workflow name */ name: string | null; /** Workflow steps */ @@ -2805,6 +2807,8 @@ export type WorkflowCreateDto = { description?: string | null; /** Workflow enabled */ enabled?: boolean; + /** Workflow logs run results */ + logging?: boolean; /** Workflow name */ name?: string | null; steps?: WorkflowStepDto[]; @@ -2822,12 +2826,30 @@ export type WorkflowUpdateDto = { description?: string | null; /** Workflow enabled */ enabled?: boolean; + /** Workflow logs run results */ + logging?: boolean; /** Workflow name */ name?: string | null; steps?: WorkflowStepDto[]; /** Workflow trigger type */ trigger?: WorkflowTrigger; }; +export type WorkflowLogEntryDto = { + /** Workflow run date/time */ + at: string; + /** Workflow log entry ID */ + id: string; + /** Last step ran, if the workflow ended early */ + lastStep?: { + /** Index of the step in the workflow */ + index: number; + /** Method of the step */ + method: string; + }; + result: WorkflowResult; + /** Workflow trigger data ID */ + triggerDataId?: string; +}; export type WorkflowShareStepDto = { /** Step configuration */ config: { @@ -6977,10 +6999,11 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { /** * List all workflows */ -export function searchWorkflows({ description, enabled, id, name, trigger }: { +export function searchWorkflows({ description, enabled, id, logging, name, trigger }: { description?: string; enabled?: boolean; id?: string; + logging?: boolean; name?: string; trigger?: WorkflowTrigger; }, opts?: Oazapfts.RequestOpts) { @@ -6991,6 +7014,7 @@ export function searchWorkflows({ description, enabled, id, name, trigger }: { description, enabled, id, + logging, name, trigger }))}`, { @@ -7063,6 +7087,26 @@ export function updateWorkflow({ id, workflowUpdateDto }: { body: workflowUpdateDto }))); } +/** + * Retrieve workflow logs + */ +export function getWorkflowLogs({ before, id, limit, result }: { + before?: string; + id: string; + limit?: number; + result?: WorkflowResult; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowLogEntryDto[]; + }>(`/workflows/${encodeURIComponent(id)}/logs${QS.query(QS.explode({ + before, + limit, + result + }))}`, { + ...opts + })); +} /** * Retrieve a workflow */ @@ -7310,6 +7354,7 @@ export enum Permission { WorkflowRead = "workflow.read", WorkflowUpdate = "workflow.update", WorkflowDelete = "workflow.delete", + WorkflowLogs = "workflow.logs", AdminUserCreate = "adminUser.create", AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", @@ -7423,7 +7468,8 @@ export enum WorkflowType { } export enum WorkflowTrigger { AssetCreate = "AssetCreate", - AssetMetadataExtraction = "AssetMetadataExtraction" + AssetMetadataExtraction = "AssetMetadataExtraction", + AssetTagged = "AssetTagged" } export enum QueueJobStatus { Active = "active", @@ -7686,6 +7732,11 @@ export enum AssetOrderBy { TakenAt = "takenAt", CreatedAt = "createdAt" } +export enum WorkflowResult { + Completed = "completed", + Halted = "halted", + Error = "error" +} export enum ReleaseType { Major = "major", Premajor = "premajor", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44d920aaa9..a24e61a884 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,16 +34,16 @@ importers: dependencies: '@docusaurus/core': specifier: ~3.10.0 - version: 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + version: 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/preset-classic': specifier: ~3.10.0 - version: 3.10.2(@algolia/client-search@5.56.0)(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@8.1.1)(uglify-js@3.19.3) + version: 3.10.2(@algolia/client-search@5.56.0)(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/theme-common': specifier: ~3.10.0 - version: 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + version: 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/theme-mermaid': specifier: ~3.10.0 - version: 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + version: 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@mdi/js': specifier: ^7.3.67 version: 7.4.47 @@ -52,25 +52,25 @@ importers: version: 1.6.1 '@mdx-js/react': specifier: ^3.0.0 - version: 3.1.1(@types/react@19.2.17)(react@19.2.8) + version: 3.1.1(@types/react@19.2.18)(react@19.2.8) autoprefixer: specifier: ^10.4.17 - version: 10.5.4(postcss@8.5.25) + version: 10.5.4(postcss@8.5.26) docusaurus-lunr-search: specifier: ^3.3.2 - version: 3.6.0(@docusaurus/core@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 3.6.0(@docusaurus/core@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) lunr: specifier: ^2.3.9 version: 2.3.9 postcss: specifier: ^8.4.25 - version: 8.5.25 + version: 8.5.26 prism-react-renderer: specifier: ^2.3.1 version: 2.4.1(react@19.2.8) raw-loader: specifier: ^4.0.2 - version: 4.0.2(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + version: 4.0.2(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) react: specifier: ^19.0.0 version: 19.2.8 @@ -79,20 +79,20 @@ importers: version: 19.2.8(react@19.2.8) tailwindcss: specifier: ^3.2.4 - version: 3.4.19(tsx@4.23.1)(yaml@2.9.0) + version: 3.4.19(tsx@4.23.9)(yaml@2.9.0) url: specifier: ^0.11.0 version: 0.11.4 devDependencies: '@docusaurus/module-type-aliases': specifier: ~3.10.0 - version: 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + version: 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/tsconfig': specifier: ^3.10.0 version: 3.10.2 '@docusaurus/types': specifier: ^3.10.0 - version: 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + version: 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -122,19 +122,19 @@ importers: version: link:../packages/sdk '@playwright/test': specifier: ^1.44.1 - version: 1.62.0 + version: 1.62.1 '@socket.io/component-emitter': specifier: ^3.1.2 version: 3.1.2 '@types/luxon': specifier: ^3.4.2 - version: 3.7.2 + version: 3.7.3 '@types/node': specifier: ^24.13.3 version: 24.13.3 '@types/pg': specifier: ^8.15.1 - version: 8.20.0 + version: 8.20.4 '@types/pngjs': specifier: ^6.0.4 version: 6.0.5 @@ -164,7 +164,7 @@ importers: version: 35.21.0 globals: specifier: ^17.0.0 - version: 17.7.0 + version: 17.9.0 luxon: specifier: ^3.4.4 version: 3.7.2 @@ -194,13 +194,13 @@ importers: version: '@typescript/typescript6@6.0.2' typescript-eslint: specifier: ^8.28.0 - version: 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) utimes: specifier: ^5.2.1 version: 5.2.1(supports-color@8.1.1) vitest: specifier: ^4.0.0 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) packages/cli: dependencies: @@ -273,7 +273,7 @@ importers: version: 72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) globals: specifier: ^17.0.0 - version: 17.7.0 + version: 17.9.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -288,13 +288,13 @@ importers: version: '@typescript/typescript6@6.0.2' typescript-eslint: specifier: ^8.58.0 - version: 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) vite: specifier: ^8.0.0 - version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) vitest: specifier: ^4.0.0 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) vitest-fetch-mock: specifier: ^0.4.0 version: 0.4.5(vitest@4.1.10) @@ -306,16 +306,16 @@ importers: devDependencies: '@types/oidc-provider': specifier: ^9.0.0 - version: 9.5.0 + version: 9.11.0 jose: specifier: ^6.0.0 - version: 6.2.4 + version: 6.2.8 oidc-provider: specifier: ^9.0.0 - version: 9.10.0(supports-color@8.1.1) + version: 9.11.2(supports-color@8.1.1) tsx: specifier: ^4.20.6 - version: 4.23.1 + version: 4.23.9 packages/plugin-core: devDependencies: @@ -330,7 +330,7 @@ importers: version: link:../sdk esbuild: specifier: ^0.28.0 - version: 0.28.1 + version: 0.28.2 typescript: specifier: ^7.0.0 version: 7.0.2 @@ -352,7 +352,7 @@ importers: version: 24.13.3 esbuild: specifier: ^0.28.0 - version: 0.28.1 + version: 0.28.2 tsc-alias: specifier: ^1.8.16 version: 1.9.1 @@ -374,13 +374,13 @@ importers: version: 24.13.3 '@types/semver': specifier: ^7.7.1 - version: 7.7.1 + version: 7.8.0 vite: specifier: ^8.0.16 - version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) vitest: specifier: ^4.1.8 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) packages/sdk: dependencies: @@ -408,7 +408,7 @@ importers: version: 0.5.2 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(bullmq@5.81.2(supports-color@8.1.1)) + version: 11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(bullmq@5.81.3(supports-color@8.1.1)) '@nestjs/common': specifier: ^11.0.4 version: 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) @@ -486,7 +486,7 @@ importers: version: 2.3.0(supports-color@8.1.1) bullmq: specifier: ^5.51.0 - version: 5.81.2(supports-color@8.1.1) + version: 5.81.3(supports-color@8.1.1) chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -534,10 +534,10 @@ importers: version: 5.11.1(supports-color@8.1.1) jose: specifier: ^6.0.0 - version: 6.2.4 + version: 6.2.8 js-yaml: specifier: ^5.0.0 - version: 5.2.2 + version: 5.2.3 jsonwebtoken: specifier: ^9.0.2 version: 9.0.3 @@ -576,7 +576,7 @@ importers: version: 5.5.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/swagger@11.4.6(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6) nodemailer: specifier: ^9.0.0 - version: 9.0.3 + version: 9.0.4 openid-client: specifier: ^6.3.3 version: 6.8.4 @@ -621,7 +621,7 @@ importers: version: 4.8.3(supports-color@8.1.1) tailwindcss-preset-email: specifier: ^1.4.0 - version: 1.4.1(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0)) + version: 1.4.1(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)) thumbhash: specifier: ^0.1.1 version: 0.1.1 @@ -646,7 +646,7 @@ importers: version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) '@nestjs/cli': specifier: ^11.0.2 - version: 11.0.24(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@24.13.3)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(prettier@3.9.6)(uglify-js@3.19.3) + version: 11.0.24(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@24.13.3)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(prettier@3.9.6)(uglify-js@3.19.3) '@nestjs/schematics': specifier: ^11.0.0 version: 11.1.0(@typescript/typescript6@6.0.2)(chokidar@4.0.3)(prettier@3.9.6) @@ -655,7 +655,7 @@ importers: version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) '@swc/core': specifier: ^1.4.14 - version: 1.15.46(@swc/helpers@0.5.23) + version: 1.15.47(@swc/helpers@0.5.23) '@types/archiver': specifier: ^7.0.0 version: 7.0.0 @@ -688,10 +688,10 @@ importers: version: 9.0.10 '@types/lodash': specifier: ^4.14.197 - version: 4.17.24 + version: 4.17.25 '@types/luxon': specifier: ^3.6.2 - version: 3.7.2 + version: 3.7.3 '@types/mock-fs': specifier: ^4.13.1 version: 4.13.4 @@ -712,10 +712,10 @@ importers: version: 6.0.5 '@types/react': specifier: ^19.0.0 - version: 19.2.17 + version: 19.2.18 '@types/semver': specifier: ^7.5.8 - version: 7.7.1 + version: 7.8.0 '@types/supertest': specifier: ^7.0.0 version: 7.2.1 @@ -730,7 +730,7 @@ importers: version: typescript@7.0.2 '@vitest/coverage-v8': specifier: ^4.0.0 - version: 4.1.10(vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) eslint: specifier: ^10.0.0 version: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) @@ -745,7 +745,7 @@ importers: version: 72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) globals: specifier: ^17.0.0 - version: 17.7.0 + version: 17.9.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -766,31 +766,31 @@ importers: version: 7.2.2(supports-color@8.1.1) tailwindcss: specifier: ^3.4.0 - version: 3.4.19(tsx@4.23.1)(yaml@2.9.0) + version: 3.4.19(tsx@4.23.9)(yaml@2.9.0) testcontainers: specifier: ^12.0.0 - version: 12.0.4(supports-color@8.1.1) + version: 12.1.0(supports-color@8.1.1) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' typescript-eslint: specifier: ^8.28.0 - version: 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) unplugin-swc: specifier: ^1.4.5 - version: 1.5.9(@swc/core@1.15.46(@swc/helpers@0.5.23))(rollup@4.62.0) + version: 1.5.10(@swc/core@1.15.47(@swc/helpers@0.5.23))(rollup@4.62.0) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) vitest: specifier: ^3.0.0 - version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) web: dependencies: '@formatjs/icu-messageformat-parser': specifier: ^3.0.0 - version: 3.5.15 + version: 3.5.16 '@immich/justified-layout-wasm': specifier: ^0.4.3 version: 0.4.3 @@ -798,8 +798,8 @@ importers: specifier: workspace:* version: link:../packages/sdk '@immich/ui': - specifier: ^0.83.0 - version: 0.83.0(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + specifier: ^0.85.0 + version: 0.85.0(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) '@mapbox/mapbox-gl-rtl-text': specifier: 0.4.0 version: 0.4.0 @@ -808,25 +808,25 @@ importers: version: 7.4.47 '@noble/hashes': specifier: ^2.2.0 - version: 2.2.0 + version: 2.3.0 '@photo-sphere-viewer/core': specifier: ^5.14.0 - version: 5.15.0 + version: 5.15.1 '@photo-sphere-viewer/equirectangular-video-adapter': specifier: ^5.14.0 - version: 5.15.0(@photo-sphere-viewer/core@5.15.0)(@photo-sphere-viewer/video-plugin@5.15.0(@photo-sphere-viewer/core@5.15.0)) + version: 5.15.1(@photo-sphere-viewer/core@5.15.1)(@photo-sphere-viewer/video-plugin@5.15.1(@photo-sphere-viewer/core@5.15.1)) '@photo-sphere-viewer/markers-plugin': specifier: ^5.14.0 - version: 5.15.0(@photo-sphere-viewer/core@5.15.0) + version: 5.15.1(@photo-sphere-viewer/core@5.15.1) '@photo-sphere-viewer/resolution-plugin': specifier: ^5.14.0 - version: 5.15.0(@photo-sphere-viewer/core@5.15.0)(@photo-sphere-viewer/settings-plugin@5.15.0(@photo-sphere-viewer/core@5.15.0)) + version: 5.15.1(@photo-sphere-viewer/core@5.15.1)(@photo-sphere-viewer/settings-plugin@5.15.1(@photo-sphere-viewer/core@5.15.1)) '@photo-sphere-viewer/settings-plugin': specifier: ^5.14.0 - version: 5.15.0(@photo-sphere-viewer/core@5.15.0) + version: 5.15.1(@photo-sphere-viewer/core@5.15.1) '@photo-sphere-viewer/video-plugin': specifier: ^5.14.0 - version: 5.15.0(@photo-sphere-viewer/core@5.15.0) + version: 5.15.1(@photo-sphere-viewer/core@5.15.1) '@types/geojson': specifier: ^7946.0.16 version: 7946.0.16 @@ -835,7 +835,7 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + version: 0.3.9(svelte@5.56.8(@typescript-eslint/types@8.66.0)) dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -859,10 +859,10 @@ importers: version: 1.5.11 hls.js: specifier: ^1.6.16 - version: 1.6.16 + version: 1.6.17 intl-messageformat: specifier: ^11.0.0 - version: 11.2.12 + version: 11.2.13 justified-layout: specifier: ^4.1.0 version: 4.1.0 @@ -886,7 +886,7 @@ importers: version: 1.5.4 simple-icons: specifier: ^16.0.0 - version: 16.27.1 + version: 16.28.0 socket.io-client: specifier: ~4.8.0 version: 4.8.3(supports-color@8.1.1) @@ -895,16 +895,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + version: 4.0.1(svelte@5.56.8(@typescript-eslint/types@8.66.0)) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.13.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + version: 3.13.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) svelte-maplibre: specifier: ^1.2.5 - version: 1.3.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + version: 1.3.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + version: 0.12.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) tabbable: specifier: ^6.2.0 version: 6.5.0 @@ -913,7 +913,7 @@ importers: version: 3.6.0 tailwind-variants: specifier: ^3.2.2 - version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3) + version: 3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.3) thumbhash: specifier: ^0.1.1 version: 0.1.1 @@ -938,31 +938,31 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))) + version: 3.0.10(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))) '@sveltejs/enhanced-img': specifier: ^0.11.0 - version: 0.11.0(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(rollup@4.62.0)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.11.0(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(rollup@4.62.0)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@sveltejs/kit': specifier: ^2.56.1 - version: 2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@sveltejs/vite-plugin-svelte': specifier: 7.2.0 - version: 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: ^7.0.0 version: 7.0.0(@testing-library/dom@10.4.1) '@testing-library/svelte': specifier: ^5.2.8 - version: 5.4.2(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.10) + version: 5.4.2(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))(vitest@4.1.10) '@testing-library/user-event': specifier: ^14.5.2 - version: 14.6.1(@testing-library/dom@10.4.1) + version: 14.6.3(@testing-library/dom@10.4.1) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 - version: 6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(prettier@3.9.6)(supports-color@8.1.1)(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + version: 6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)))(prettier@3.9.6)(supports-color@8.1.1)(svelte@5.56.8(@typescript-eslint/types@8.66.0)) '@types/chromecast-caf-sender': specifier: ^1.0.11 version: 1.0.11 @@ -977,7 +977,7 @@ importers: version: 4.17.12 '@types/luxon': specifier: ^3.4.2 - version: 3.7.2 + version: 3.7.3 '@types/qrcode': specifier: ^1.5.5 version: 1.5.6 @@ -1004,7 +1004,7 @@ importers: version: 7.0.2(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + version: 3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) eslint-plugin-unicorn: specifier: ^72.0.0 version: 72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) @@ -1013,7 +1013,7 @@ importers: version: 1.4.2 globals: specifier: ^17.0.0 - version: 17.7.0 + version: 17.9.0 prettier: specifier: ^3.7.4 version: 3.9.6 @@ -1022,19 +1022,19 @@ importers: version: 4.2.0(prettier@3.9.6) prettier-plugin-svelte: specifier: ^4.0.0 - version: 4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + version: 4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)) rollup-plugin-visualizer: specifier: ^7.0.0 - version: 7.0.1(rolldown@1.1.5)(rollup@4.62.0) + version: 7.0.1(rolldown@1.2.3)(rollup@4.62.0) svelte: specifier: 5.56.8 - version: 5.56.8(@typescript-eslint/types@8.65.0) + version: 5.56.8(@typescript-eslint/types@8.66.0) svelte-check: specifier: ^4.4.6 - version: 4.7.3(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + version: 4.7.4(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.8(@typescript-eslint/types@8.66.0)) svelte-eslint-parser: - specifier: ^1.3.3 - version: 1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + specifier: ^1.8.1 + version: 1.8.1(svelte@5.56.8(@typescript-eslint/types@8.66.0)) tailwindcss: specifier: ^4.2.4 version: 4.3.3 @@ -1043,13 +1043,13 @@ importers: version: '@typescript/typescript6@6.0.2' typescript-eslint: specifier: ^8.45.0 - version: 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) vite: specifier: ^8.0.0 - version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) vitest: specifier: ^4.0.0 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) packages: @@ -2336,15 +2336,9 @@ packages: resolution: {integrity: sha512-xx0W3eav2uW1NRIpuHJWNwLTC15xPNjU4Uxi9NSnd3swYC96BE3vFiT93SD8s24kmAAWNwgZwfZ2fghGZ01Lcw==} engines: {node: '>=20.0'} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} engines: {node: '>=12'} @@ -2357,8 +2351,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -2375,8 +2369,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -2393,8 +2387,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -2411,8 +2405,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -2429,8 +2423,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -2447,8 +2441,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -2465,8 +2459,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -2483,8 +2477,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -2501,8 +2495,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -2519,8 +2513,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -2537,8 +2531,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -2555,8 +2549,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -2573,8 +2567,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -2591,8 +2585,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -2609,8 +2603,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -2627,8 +2621,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -2645,8 +2639,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -2657,8 +2651,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -2675,8 +2669,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -2687,8 +2681,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -2705,8 +2699,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -2717,8 +2711,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -2735,8 +2729,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -2753,8 +2747,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -2771,8 +2765,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -2789,8 +2783,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2874,8 +2868,8 @@ packages: '@formatjs/icu-messageformat-parser@2.11.4': resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} - '@formatjs/icu-messageformat-parser@3.5.15': - resolution: {integrity: sha512-5o4grXKotAB3JqQuisLApHG43g17N+paoRTa92Jiz35Zvfemq0cVf4EDvuxyHAzmsJji7igaEowicLO/VmfJ8Q==} + '@formatjs/icu-messageformat-parser@3.5.16': + resolution: {integrity: sha512-kl6b/4D56gjGZi4ZewSmvXbalHwjOUI5ogEHPZqw42goeXTTrL7/yuPzvdrvr0QigDtvaOeb+UeMf62jks43Yg==} '@formatjs/icu-skeleton-parser@1.8.16': resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} @@ -3110,8 +3104,8 @@ packages: resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==} hasBin: true - '@immich/ui@0.83.0': - resolution: {integrity: sha512-Xh3R3yhn8/Qyq8lGWIiVmturA+dHXtmW/h++AGb2UAwS/58qu7ssocO/HWIamaSpkgjPGotlWpPIpUJq1zh9FQ==} + '@immich/ui@0.85.0': + resolution: {integrity: sha512-KCAEtVGexZ1G+nuT25bv1QS6Rc2wOlLymp4qDBZVqMxu3o3RWpUzafRHvZt5g4FnlAgfHZdXAIHVVrhJzkrlDA==} peerDependencies: '@sveltejs/kit': ^2.13.0 svelte: ^5.0.0 @@ -3580,12 +3574,6 @@ packages: '@namnode/store@0.1.0': resolution: {integrity: sha512-4NGTldxKcmY0UuZ7OEkvCjs8ZEoeYB6M2UwMu74pdLiFMKxXbj9HdNk1Qn213bxX1O7bY5h+PLh5DZsTURZkYA==} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@nestjs/bull-shared@11.0.4': resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} peerDependencies: @@ -3735,8 +3723,8 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@noble/hashes@2.2.0': - resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} '@nodelib/fs.scandir@2.1.5': @@ -3968,8 +3956,8 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -4099,35 +4087,35 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} - '@photo-sphere-viewer/core@5.15.0': - resolution: {integrity: sha512-Mf72l3R8iBf+Im5duv7IVkX3uiKU1BT6bzzXmdE43KahQgNftt9pYKqTzN1YjaYLAzWDBCFZj/bEK1dIZXk9Gg==} + '@photo-sphere-viewer/core@5.15.1': + resolution: {integrity: sha512-nhJviczUB1xiAT/I7mGba9iDIw7DvsiK34hbR9t7kIrg9MySG33uA+a4QUk2dEOP5PjcKMj58wod8I/dQ7ePiw==} - '@photo-sphere-viewer/equirectangular-video-adapter@5.15.0': - resolution: {integrity: sha512-Gro1wUA5qTla71uSeGlQFr9BenNXrI9Xee4a+dveafqrZJ35/n+nDhxrc8I4osykyvC6Foc0qgksAsaNsMKX/w==} + '@photo-sphere-viewer/equirectangular-video-adapter@5.15.1': + resolution: {integrity: sha512-510h1cDBAe+qoHVrR5IwUE/jnmzz0lA9mXHN7yzJDsrixPRh67gXEB3KIEc2Cgj/B1u1D2WK2W7OwhkQ40AX0A==} peerDependencies: - '@photo-sphere-viewer/core': 5.15.0 - '@photo-sphere-viewer/video-plugin': 5.15.0 + '@photo-sphere-viewer/core': 5.15.1 + '@photo-sphere-viewer/video-plugin': 5.15.1 - '@photo-sphere-viewer/markers-plugin@5.15.0': - resolution: {integrity: sha512-r1Kv4BkvcOf+77QU7k0pHzZ3O0IrX+ff7arttmb0D81HQHRl/fUcE+tXu+mGDj9B1zuSi1LcvKOsUI9m96owNw==} + '@photo-sphere-viewer/markers-plugin@5.15.1': + resolution: {integrity: sha512-57WiHy8fcQfwid9x07ADiQJGxzcFxclbR2yIqeggzfPDguwJlCKwX5tj8brTRqwouUiHpkb80QWxHByxt45LcA==} peerDependencies: - '@photo-sphere-viewer/core': 5.15.0 + '@photo-sphere-viewer/core': 5.15.1 - '@photo-sphere-viewer/resolution-plugin@5.15.0': - resolution: {integrity: sha512-8Rm9r9WJX5m8N0Q7ezUEHUgxWybroj6HhfaksINUm7o8WegqZ+Pp2izEEoXI57kkjfudYOp7abrwA/J3I+QRrA==} + '@photo-sphere-viewer/resolution-plugin@5.15.1': + resolution: {integrity: sha512-Sp14tgFv1XRkXbsFO+dWSgfwGjxB4ibsB+wXe64M4RI/NSDu+1Uzqtxj85Vbz7gOw3CaP1e8acXrd/IEg6CEHg==} peerDependencies: - '@photo-sphere-viewer/core': 5.15.0 - '@photo-sphere-viewer/settings-plugin': 5.15.0 + '@photo-sphere-viewer/core': 5.15.1 + '@photo-sphere-viewer/settings-plugin': 5.15.1 - '@photo-sphere-viewer/settings-plugin@5.15.0': - resolution: {integrity: sha512-xZot+fT5R+Jy7D/QEVoylIMg/R+l/iSBykQ1v8wqGkSrUqJdVkNv3w3ksycb9BYFt/78P8HU7AGvJgIF6J3EFQ==} + '@photo-sphere-viewer/settings-plugin@5.15.1': + resolution: {integrity: sha512-0FkCUUEsdTa4iLZu9nVLsjuh459hKKZeh+JiXfPzfrGLcLODLUwJugDjFHe/4KH7mmW9n7KbSUWBD8POvkUqqA==} peerDependencies: - '@photo-sphere-viewer/core': 5.15.0 + '@photo-sphere-viewer/core': 5.15.1 - '@photo-sphere-viewer/video-plugin@5.15.0': - resolution: {integrity: sha512-RG70vRu+suV7QT1WbnxVknm8KVU26wCZx4NUofFAENYSvyldlRxmVK3XqCh1WChWhcjrXqj3woz4vnzrykVOzQ==} + '@photo-sphere-viewer/video-plugin@5.15.1': + resolution: {integrity: sha512-jrZh2BGZA8BGzNj+gzDntfX8bd4kt0vIBsyAslSgPpkJUTRKxZ2nTIp2aP1OQ4EtpP3g4kbqrIAe6Avdn6pr0A==} peerDependencies: - '@photo-sphere-viewer/core': 5.15.0 + '@photo-sphere-viewer/core': 5.15.1 '@photostructure/tz-lookup@11.5.0': resolution: {integrity: sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==} @@ -4140,8 +4128,8 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} - '@playwright/test@1.62.0': - resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} hasBin: true @@ -4380,97 +4368,92 @@ packages: '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + '@rolldown/binding-android-arm64@1.2.3': + resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + '@rolldown/binding-darwin-arm64@1.2.3': + resolution: {integrity: sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + '@rolldown/binding-darwin-x64@1.2.3': + resolution: {integrity: sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + '@rolldown/binding-freebsd-x64@1.2.3': + resolution: {integrity: sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + resolution: {integrity: sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + '@rolldown/binding-linux-arm64-gnu@1.2.3': + resolution: {integrity: sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + '@rolldown/binding-linux-arm64-musl@1.2.3': + resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + '@rolldown/binding-linux-s390x-gnu@1.2.3': + resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + '@rolldown/binding-linux-x64-gnu@1.2.3': + resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + '@rolldown/binding-linux-x64-musl@1.2.3': + resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + '@rolldown/binding-openharmony-arm64@1.2.3': + resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + '@rolldown/binding-win32-arm64-msvc@1.2.3': + resolution: {integrity: sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + '@rolldown/binding-win32-x64-msvc@1.2.3': + resolution: {integrity: sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -4675,8 +4658,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@sveltejs/acorn-typescript@1.0.11': - resolution: {integrity: sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==} + '@sveltejs/acorn-typescript@1.0.12': + resolution: {integrity: sha512-J1jNYG23QWd67UfrQSFHtjhV37r9mVi0gdc12A3MWPldOjRK35Xk+um+qACPVjgw3AleiqoyEAhok8Wm3q46NA==} peerDependencies: acorn: ^8.9.0 @@ -4692,8 +4675,8 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.70.1': - resolution: {integrity: sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==} + '@sveltejs/kit@2.70.2': + resolution: {integrity: sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -4708,8 +4691,8 @@ packages: typescript: optional: true - '@sveltejs/load-config@0.2.0': - resolution: {integrity: sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==} + '@sveltejs/load-config@0.2.2': + resolution: {integrity: sha512-K7dsJDQxBOF+f+epuhMactcjK2VP4MRkLKtwSykNtEI+cKVEyzrmmhQ1pmoxI800m4JKcyJ05L2M4yPwAGiBNw==} engines: {node: '>= 18.0.0'} '@sveltejs/vite-plugin-svelte@7.2.0': @@ -4797,86 +4780,86 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} - '@swc/core-darwin-arm64@1.15.46': - resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} + '@swc/core-darwin-arm64@1.15.47': + resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.46': - resolution: {integrity: sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==} + '@swc/core-darwin-x64@1.15.47': + resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.46': - resolution: {integrity: sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==} + '@swc/core-linux-arm-gnueabihf@1.15.47': + resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.46': - resolution: {integrity: sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==} + '@swc/core-linux-arm64-gnu@1.15.47': + resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.46': - resolution: {integrity: sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==} + '@swc/core-linux-arm64-musl@1.15.47': + resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-ppc64-gnu@1.15.46': - resolution: {integrity: sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==} + '@swc/core-linux-ppc64-gnu@1.15.47': + resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] libc: [glibc] - '@swc/core-linux-s390x-gnu@1.15.46': - resolution: {integrity: sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==} + '@swc/core-linux-s390x-gnu@1.15.47': + resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==} engines: {node: '>=10'} cpu: [s390x] os: [linux] libc: [glibc] - '@swc/core-linux-x64-gnu@1.15.46': - resolution: {integrity: sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==} + '@swc/core-linux-x64-gnu@1.15.47': + resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.46': - resolution: {integrity: sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==} + '@swc/core-linux-x64-musl@1.15.47': + resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.46': - resolution: {integrity: sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==} + '@swc/core-win32-arm64-msvc@1.15.47': + resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.46': - resolution: {integrity: sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==} + '@swc/core-win32-ia32-msvc@1.15.47': + resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.46': - resolution: {integrity: sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==} + '@swc/core-win32-x64-msvc@1.15.47': + resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.46': - resolution: {integrity: sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==} + '@swc/core@1.15.47': + resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -5020,8 +5003,8 @@ packages: vitest: optional: true - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + '@testing-library/user-event@14.6.3': + resolution: {integrity: sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -5061,9 +5044,6 @@ packages: '@turf/invariant@7.3.5': resolution: {integrity: sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/accepts@1.3.7': resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} @@ -5345,11 +5325,11 @@ packages: '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - '@types/lodash@4.17.24': - resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} - '@types/luxon@3.7.2': - resolution: {integrity: sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==} + '@types/luxon@3.7.3': + resolution: {integrity: sha512-pE7BSbKHiojpl4v7iEzdfFLXXJaH5RPJxI3Wr4x3HU59l83PJfhcUx4mGPWq245+DCAENAzRF/+ZoYr2tJCe/g==} '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -5387,8 +5367,8 @@ packages: '@types/nodemailer@8.0.1': resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} - '@types/oidc-provider@9.5.0': - resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==} + '@types/oidc-provider@9.11.0': + resolution: {integrity: sha512-wZMEmCGh8WGKYSUpxJgpPLIKTe4972OJUlWXaNzy5+vvnU097i8ijzApgHLihdg56cQ+PV+USYYcnh3W8kVISA==} '@types/parse5@5.0.3': resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==} @@ -5399,8 +5379,8 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} - '@types/pg@8.20.0': - resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/pg@8.20.4': + resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==} '@types/picomatch@4.0.3': resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} @@ -5429,8 +5409,8 @@ packages: '@types/react-router@5.1.20': resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} @@ -5441,8 +5421,8 @@ packages: '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -5507,63 +5487,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + '@typescript-eslint/eslint-plugin@8.66.0': + resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.65.0 + '@typescript-eslint/parser': ^8.66.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + '@typescript-eslint/parser@8.66.0': + resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.66.0': + resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + '@typescript-eslint/scope-manager@8.66.0': + resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + '@typescript-eslint/type-utils@8.66.0': + resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/typescript-estree@8.66.0': + resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.66.0': + resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + '@typescript-eslint/visitor-keys@8.66.0': + resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/typescript-aix-ppc64@7.0.2': @@ -6139,9 +6119,9 @@ packages: bare-abort-controller: optional: true - bare-fs@4.7.4: - resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} - engines: {bare: '>=1.16.0'} + bare-fs@4.8.0: + resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==} + engines: {bare: '>=1.28.0'} peerDependencies: bare-buffer: '*' peerDependenciesMeta: @@ -6165,8 +6145,8 @@ packages: bare-events: optional: true - bare-url@2.4.5: - resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + bare-url@2.5.1: + resolution: {integrity: sha512-cD5ciQuKlx+eumTCfqbfiL+fhQm+dHbVNB/cX4+d+I/nx4JsVop9VoFEPAs5RJ6I84QR6bZIBjpd2kjDOYeWcg==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -6283,8 +6263,8 @@ packages: resolution: {integrity: sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==} engines: {node: '>=18.20'} - bullmq@5.81.2: - resolution: {integrity: sha512-Hi9GaVCC6HE9bQP65j/FNv1aL1fcEukTF99ezS5pl1Ud+joCFpNWiPCV45mWdkEmpuacS0XJdMMFGKPIHCwoPg==} + bullmq@5.81.3: + resolution: {integrity: sha512-Q7uEH2G92rVjX3Yl2qw3+6VEO15uxehC6DfeTHQBQTehddcRDSDuquD9zuvDjxiCffXsPUaBwrRlvlbVcgKs7g==} engines: {node: '>=12.22.0'} peerDependencies: redis: '>=5.0.0' @@ -7268,8 +7248,8 @@ packages: engines: {node: '>= 16.0.0'} hasBin: true - devalue@5.8.2: - resolution: {integrity: sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==} + devalue@5.9.0: + resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -7525,8 +7505,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -8016,6 +7996,10 @@ packages: resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} engines: {node: '>=18'} + fuse.js@7.5.0: + resolution: {integrity: sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==} + engines: {node: '>=10'} + gauge@3.0.2: resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} engines: {node: '>=10'} @@ -8129,8 +8113,8 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globals@17.7.0: - resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + globals@17.9.0: + resolution: {integrity: sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==} engines: {node: '>=18'} globalyzer@0.1.0: @@ -8278,8 +8262,8 @@ packages: hls-video-element@1.5.11: resolution: {integrity: sha512-tJJ65/52CDxj8XFyIve6zT9nVVdUIc6mqvKR25X0ycPKHk07rpjp4xxVteeCefDUBSf/tFLhlICFmn3KWj37xA==} - hls.js@1.6.16: - resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + hls.js@1.6.17: + resolution: {integrity: sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==} hogan.js@3.0.2: resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==} @@ -8515,8 +8499,8 @@ packages: intl-messageformat@10.7.18: resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} - intl-messageformat@11.2.12: - resolution: {integrity: sha512-KW70Xxfcvy7vV3qODfvShWkFDPMqKDAa4N+hSyVBWGNtVhTUFYaqlD/l88DaYPKiVcPP4rPQ3qnH7i5K82Mg7g==} + intl-messageformat@11.2.13: + resolution: {integrity: sha512-JaPaE6TIX+TAS5XLhDUh41geLw4QfBHX4s5pW8Km+L9fVC8HzB9yOuhbh4EMR/F1+8C6b9qk4763Cv+LdOG1kg==} invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -8779,8 +8763,8 @@ packages: joi@17.13.4: resolution: {integrity: sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==} - jose@6.2.4: - resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -8799,8 +8783,8 @@ packages: resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} hasBin: true - js-yaml@5.2.2: - resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==} + js-yaml@5.2.3: + resolution: {integrity: sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==} hasBin: true jsdom@26.1.0: @@ -9732,8 +9716,8 @@ packages: resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true - msgpackr@2.0.4: - resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} multer@2.2.0: resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} @@ -9763,13 +9747,13 @@ packages: nan@2.28.0: resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@6.0.0: - resolution: {integrity: sha512-mkUH+rPkwU2qPadJ0oJZOjeZ5Mxn8Q1UhevwkTRWNuUZzyia3h4rhzK39hxaHTk0o2OxB8W2SQ6A8k23ZDi1pQ==} + nanoid@6.0.1: + resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} engines: {node: ^22 || ^24 || >=26} hasBin: true @@ -9900,8 +9884,8 @@ packages: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} - nodemailer@9.0.3: - resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + nodemailer@9.0.4: + resolution: {integrity: sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==} engines: {node: '>=6.0.0'} nopt@1.0.10: @@ -9993,8 +9977,8 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} - oidc-provider@9.10.0: - resolution: {integrity: sha512-Olmg6oxgHIviZnrf9yaey6vdaWG1f19UiyvpSj0gCgj6wl9YMcOw3uDv3FHj+x62EXeFj7GfZeTL09MCgIEKnw==} + oidc-provider@9.11.2: + resolution: {integrity: sha512-Z7CZzWtBfcNMSaIoNJwHmEbla6h4mW73soOAJM4Qo9XDALMgQ1J+amMg/FBZJD1BHoec1KLL5BHFDkvlgeUZqw==} on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} @@ -10247,8 +10231,8 @@ packages: peerDependencies: pg: '>=8.0' - pg-protocol@1.15.0: - resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} @@ -10297,13 +10281,13 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} - playwright-core@1.62.0: - resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} engines: {node: '>=20'} hasBin: true - playwright@1.62.0: - resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} engines: {node: '>=20'} hasBin: true @@ -10754,8 +10738,8 @@ packages: resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} engines: {node: '>=4'} - postcss-selector-parser@7.1.4: - resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + postcss-selector-parser@7.1.5: + resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} engines: {node: '>=4'} postcss-sort-media-queries@5.2.0: @@ -10785,8 +10769,8 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -11282,8 +11266,8 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + rolldown@1.2.3: + resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -11520,8 +11504,8 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - simple-icons@16.27.1: - resolution: {integrity: sha512-slZF8iKxkv7Lb9SF3L1AcIl5iYLNvDrKKm8yV69cG66XvxJ12SX+bYTgxRwCY4bXSmklGXoXaKEyVmEypOCeqw==} + simple-icons@16.28.0: + resolution: {integrity: sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==} engines: {node: '>=0.12.18'} sirv@2.0.4: @@ -11794,17 +11778,17 @@ packages: peerDependencies: svelte: '>= 3.43.1 < 6' - svelte-check@4.7.3: - resolution: {integrity: sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==} + svelte-check@4.7.4: + resolution: {integrity: sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: '>=5.0.0' + typescript: ^5.0.0 || ^6.0.0 - svelte-eslint-parser@1.8.0: - resolution: {integrity: sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.1} + svelte-eslint-parser@1.8.1: + resolution: {integrity: sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.5} peerDependencies: svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: @@ -11921,15 +11905,17 @@ packages: tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwind-variants@3.2.2: - resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} - engines: {node: '>=16.x', pnpm: '>=7.x'} + tailwind-variants@3.3.1: + resolution: {integrity: sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==} + engines: {node: '>=16.9.x', pnpm: '>=7.x'} peerDependencies: tailwind-merge: '>=3.0.0' tailwindcss: '*' peerDependenciesMeta: tailwind-merge: optional: true + tailwindcss: + optional: true tailwindcss-email-variants@3.0.5: resolution: {integrity: sha512-BbULkY0/0VLXwSzAZP0wxyO0siXJARatreZTSW+U6dM+LuTzPsfG/SfxxuouINM1xtIoPGGU++jKkMyfYKJe7Q==} @@ -12033,8 +12019,9 @@ packages: engines: {node: '>=10'} hasBin: true - testcontainers@12.0.4: - resolution: {integrity: sha512-QIR/8xF1+F/26cIM+9B4yyxNTbKJxAv3hygZyhPRgZ8Q2AhlPZjDdpXRuk16V37X4bgJRI3hXFhoEICMBA7Adg==} + testcontainers@12.1.0: + resolution: {integrity: sha512-YjDLqIITuhGLMnM10yhg3oV6lIG5IMpz1R1DPBZoOOks83q7i7IVpeSWRTiyl7roozjiyLmwIoLK/KY8OnZmIA==} + engines: {node: '>= 22.22'} text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -12227,8 +12214,8 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.9: + resolution: {integrity: sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==} engines: {node: '>=18.0.0'} hasBin: true @@ -12283,8 +12270,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + typescript-eslint@8.66.0: + resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -12339,8 +12326,8 @@ packages: resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} engines: {node: '>=18.17'} - undici@8.7.0: - resolution: {integrity: sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} unicode-canonical-property-names-ecmascript@2.0.1: @@ -12414,8 +12401,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unplugin-swc@1.5.9: - resolution: {integrity: sha512-RKwK3yf0M+MN17xZfF14bdKqfx0zMXYdtOdxLiE6jHAoidupKq3jGdJYANyIM1X/VmABhh1WpdO+/f4+Ol89+g==} + unplugin-swc@1.5.10: + resolution: {integrity: sha512-aNvKH601KYzc/fXyDQJPAk8obNPzA4GdGqx+qSjQoyweMLDMLs5VRbunjL4skIv/hyyAnSgrbxMwXHdRTzhmew==} peerDependencies: '@swc/core': ^1.2.108 @@ -12580,13 +12567,13 @@ packages: yaml: optional: true - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -14137,302 +14124,302 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-alpha-function@1.0.1(postcss@8.5.25)': + '@csstools/postcss-alpha-function@1.0.1(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.25)': + '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)': dependencies: - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4) - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.25)': + '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-color-function@4.0.12(postcss@8.5.25)': + '@csstools/postcss-color-function@4.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-color-mix-function@3.0.12(postcss@8.5.25)': + '@csstools/postcss-color-mix-function@3.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2(postcss@8.5.25)': + '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-content-alt-text@2.0.8(postcss@8.5.25)': + '@csstools/postcss-content-alt-text@2.0.8(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-contrast-color-function@2.0.12(postcss@8.5.25)': + '@csstools/postcss-contrast-color-function@2.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-exponential-functions@2.0.9(postcss@8.5.25)': + '@csstools/postcss-exponential-functions@2.0.9(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.5.25)': + '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.5.26)': dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-gamut-mapping@2.0.11(postcss@8.5.25)': + '@csstools/postcss-gamut-mapping@2.0.11(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-gradients-interpolation-method@5.0.12(postcss@8.5.25)': + '@csstools/postcss-gradients-interpolation-method@5.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-hwb-function@4.0.12(postcss@8.5.25)': + '@csstools/postcss-hwb-function@4.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-ic-unit@4.0.4(postcss@8.5.25)': + '@csstools/postcss-ic-unit@4.0.4(postcss@8.5.26)': dependencies: - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-initial@2.0.1(postcss@8.5.25)': + '@csstools/postcss-initial@2.0.1(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.25)': + '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)': dependencies: - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4) - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.25)': + '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.5.25)': + '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-logical-overflow@2.0.0(postcss@8.5.25)': + '@csstools/postcss-logical-overflow@2.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.5.25)': + '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-logical-resize@3.0.0(postcss@8.5.25)': + '@csstools/postcss-logical-resize@3.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-logical-viewport-units@3.0.4(postcss@8.5.25)': + '@csstools/postcss-logical-viewport-units@3.0.4(postcss@8.5.26)': dependencies: '@csstools/css-tokenizer': 3.0.4 - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-media-minmax@2.0.9(postcss@8.5.25)': + '@csstools/postcss-media-minmax@2.0.9(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5(postcss@8.5.25)': + '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-nested-calc@4.0.0(postcss@8.5.25)': + '@csstools/postcss-nested-calc@4.0.0(postcss@8.5.26)': dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-normalize-display-values@4.0.1(postcss@8.5.25)': + '@csstools/postcss-normalize-display-values@4.0.1(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.25)': + '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-position-area-property@1.0.0(postcss@8.5.25)': + '@csstools/postcss-position-area-property@1.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.25)': + '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-property-rule-prelude-list@1.0.0(postcss@8.5.25)': + '@csstools/postcss-property-rule-prelude-list@1.0.0(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-random-function@2.0.1(postcss@8.5.25)': + '@csstools/postcss-random-function@2.0.1(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-relative-color-syntax@3.0.12(postcss@8.5.25)': + '@csstools/postcss-relative-color-syntax@3.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.25)': + '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)': dependencies: - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.25)': + '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-stepped-value-functions@4.0.9(postcss@8.5.25)': + '@csstools/postcss-stepped-value-functions@4.0.9(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-syntax-descriptor-syntax-production@1.0.1(postcss@8.5.25)': + '@csstools/postcss-syntax-descriptor-syntax-production@1.0.1(postcss@8.5.26)': dependencies: '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-system-ui-font-family@1.0.0(postcss@8.5.25)': + '@csstools/postcss-system-ui-font-family@1.0.0(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-text-decoration-shorthand@4.0.3(postcss@8.5.25)': + '@csstools/postcss-text-decoration-shorthand@4.0.3(postcss@8.5.26)': dependencies: '@csstools/color-helpers': 5.1.0 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-trigonometric-functions@4.0.9(postcss@8.5.25)': + '@csstools/postcss-trigonometric-functions@4.0.9(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-unset-value@4.0.0(postcss@8.5.25)': + '@csstools/postcss-unset-value@4.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.4)': + '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.5)': dependencies: - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 - '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.4)': + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.5)': dependencies: - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 - '@csstools/utilities@2.0.0(postcss@8.5.25)': + '@csstools/utilities@2.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 '@discoveryjs/json-ext@0.5.7': {} - '@docsearch/core@4.6.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docsearch/core@4.6.3(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) '@docsearch/css@4.6.3': {} - '@docsearch/react@4.6.3(@algolia/client-search@5.56.0)(@types/react@19.2.17)(algoliasearch@5.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)': + '@docsearch/react@4.6.3(@algolia/client-search@5.56.0)(@types/react@19.2.18)(algoliasearch@5.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)': dependencies: '@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3) - '@docsearch/core': 4.6.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docsearch/core': 4.6.3(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@docsearch/css': 4.6.3 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) search-insights: 2.17.3 @@ -14440,7 +14427,7 @@ snapshots: - '@algolia/client-search' - algoliasearch - '@docusaurus/babel@3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/babel@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.7 @@ -14452,7 +14439,7 @@ snapshots: '@babel/runtime': 7.29.7 '@babel/traverse': 7.29.7(supports-color@8.1.1) '@docusaurus/logger': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) babel-plugin-dynamic-import-node: 2.3.3 fs-extra: 11.3.6 tslib: 2.8.1 @@ -14474,32 +14461,32 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/bundler@3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/bundler@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) - '@docusaurus/babel': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/babel': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/cssnano-preset': 3.10.2 '@docusaurus/logger': 3.10.2 - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - babel-loader: 9.2.1(@babel/core@7.29.7(supports-color@8.1.1))(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + babel-loader: 9.2.1(@babel/core@7.29.7(supports-color@8.1.1))(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) clean-css: 5.3.3 - copy-webpack-plugin: 11.0.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) - css-loader: 6.11.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) - css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) - cssnano: 6.1.2(postcss@8.5.25) - file-loader: 6.2.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + copy-webpack-plugin: 11.0.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) + css-loader: 6.11.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) + css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) + cssnano: 6.1.2(postcss@8.5.26) + file-loader: 6.2.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) html-minifier-terser: 7.2.0 - mini-css-extract-plugin: 2.10.2(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) - null-loader: 4.0.1(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) - postcss: 8.5.25 - postcss-loader: 7.3.4(@typescript/typescript6@6.0.2)(postcss@8.5.25)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) - postcss-preset-env: 10.6.1(postcss@8.5.25) - terser-webpack-plugin: 5.6.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + mini-css-extract-plugin: 2.10.2(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) + null-loader: 4.0.1(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) + postcss: 8.5.26 + postcss-loader: 7.3.4(@typescript/typescript6@6.0.2)(postcss@8.5.26)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) + postcss-preset-env: 10.6.1(postcss@8.5.26) + terser-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)))(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) - webpackbar: 7.0.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)))(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) + webpackbar: 7.0.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) transitivePeerDependencies: - '@minify-html/node' - '@parcel/css' @@ -14517,16 +14504,16 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/core@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/babel': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/bundler': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/babel': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/bundler': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/logger': 3.10.2 - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.8) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) boxen: 6.2.1 chalk: 4.1.2 chokidar: 3.6.0 @@ -14541,7 +14528,7 @@ snapshots: execa: 5.1.1 fs-extra: 11.3.6 html-tags: 3.3.1 - html-webpack-plugin: 5.6.7(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + html-webpack-plugin: 5.6.7(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) leven: 3.1.0 lodash: 4.18.1 open: 8.4.2 @@ -14551,7 +14538,7 @@ snapshots: react-dom: 19.2.8(react@19.2.8) react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.8)' - react-loadable-ssr-addon-v5-slorber: 1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.8))(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + react-loadable-ssr-addon-v5-slorber: 1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.8))(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) react-router: 5.3.4(react@19.2.8) react-router-config: 5.1.1(react-router@5.3.4(react@19.2.8))(react@19.2.8) react-router-dom: 5.3.4(react@19.2.8) @@ -14560,9 +14547,9 @@ snapshots: tinypool: 1.1.1 tslib: 2.8.1 update-notifier: 6.0.2 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) webpack-bundle-analyzer: 4.10.2 - webpack-dev-server: 5.2.6(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tslib@2.8.1)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + webpack-dev-server: 5.2.6(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tslib@2.8.1)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) webpack-merge: 6.0.1 transitivePeerDependencies: - '@minify-html/node' @@ -14588,9 +14575,9 @@ snapshots: '@docusaurus/cssnano-preset@3.10.2': dependencies: - cssnano-preset-advanced: 6.1.2(postcss@8.5.25) - postcss: 8.5.25 - postcss-sort-media-queries: 5.2.0(postcss@8.5.25) + cssnano-preset-advanced: 6.1.2(postcss@8.5.26) + postcss: 8.5.26 + postcss-sort-media-queries: 5.2.0(postcss@8.5.26) tslib: 2.8.1 '@docusaurus/logger@3.10.2': @@ -14598,16 +14585,16 @@ snapshots: chalk: 4.1.2 tslib: 2.8.1 - '@docusaurus/mdx-loader@3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/mdx-loader@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: '@docusaurus/logger': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@mdx-js/mdx': 3.1.1(supports-color@8.1.1) '@slorber/remark-comment': 1.0.0 escape-html: 1.0.3 estree-util-value-to-estree: 3.5.0 - file-loader: 6.2.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + file-loader: 6.2.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) fs-extra: 11.3.6 image-size: 2.0.2 mdast-util-mdx: 3.0.0(supports-color@8.1.1) @@ -14623,9 +14610,9 @@ snapshots: tslib: 2.8.1 unified: 11.0.5 unist-util-visit: 5.1.0 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)))(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)))(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) vfile: 6.0.3 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) transitivePeerDependencies: - '@minify-html/node' - '@swc/core' @@ -14642,11 +14629,11 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/module-type-aliases@3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/module-type-aliases@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@types/history': 4.7.11 - '@types/react': 19.2.17 + '@types/react': 19.2.18 '@types/react-router-config': 5.0.11 '@types/react-router-dom': 5.3.3 react: 19.2.8 @@ -14669,17 +14656,17 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-content-blog@3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/logger': 3.10.2 - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) cheerio: 1.0.0-rc.12 combine-promises: 1.2.0 feed: 4.2.2 @@ -14692,7 +14679,7 @@ snapshots: tslib: 2.8.1 unist-util-visit: 5.1.0 utility-types: 3.11.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -14717,17 +14704,17 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/logger': 3.10.2 - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.3.6 @@ -14738,7 +14725,7 @@ snapshots: schema-dts: 1.1.5 tslib: 2.8.1 utility-types: 3.11.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -14763,18 +14750,18 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-content-pages@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) fs-extra: 11.3.6 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -14799,12 +14786,12 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-css-cascade-layers@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) tslib: 2.8.1 transitivePeerDependencies: - '@docusaurus/faster' @@ -14832,11 +14819,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-debug@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) fs-extra: 11.3.6 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -14866,11 +14853,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-google-analytics@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 @@ -14898,11 +14885,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-google-gtag@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 @@ -14930,11 +14917,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-google-tag-manager@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 @@ -14962,14 +14949,14 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-sitemap@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/logger': 3.10.2 - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) fs-extra: 11.3.6 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -14999,18 +14986,18 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/plugin-svgr@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@svgr/core': 8.1.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) '@svgr/webpack': 8.1.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -15035,23 +15022,23 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.10.2(@algolia/client-search@5.56.0)(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/preset-classic@3.10.2(@algolia/client-search@5.56.0)(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-content-blog': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-content-pages': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-css-cascade-layers': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-debug': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-google-analytics': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-google-gtag': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-google-tag-manager': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-sitemap': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-svgr': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/theme-classic': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/theme-search-algolia': 3.10.2(@algolia/client-search@5.56.0)(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-content-blog': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-content-pages': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-css-cascade-layers': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-debug': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-google-analytics': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-google-gtag': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-google-tag-manager': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-sitemap': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-svgr': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/theme-classic': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/theme-search-algolia': 3.10.2(@algolia/client-search@5.56.0)(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: @@ -15083,31 +15070,31 @@ snapshots: '@docusaurus/react-loadable@6.0.0(react@19.2.8)': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 react: 19.2.8 - '@docusaurus/theme-classic@3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/theme-classic@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/logger': 3.10.2 - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-content-blog': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-content-pages': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-content-blog': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-content-pages': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/theme-translations': 3.10.2 - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.8) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) clsx: 2.1.1 copy-text-to-clipboard: 3.2.2 infima: 0.2.0-alpha.45 lodash: 4.18.1 nprogress: 0.2.0 - postcss: 8.5.25 + postcss: 8.5.26 prism-react-renderer: 2.4.1(react@19.2.8) prismjs: 1.30.0 react: 19.2.8 @@ -15139,15 +15126,15 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/theme-common@3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@types/history': 4.7.11 - '@types/react': 19.2.17 + '@types/react': 19.2.18 '@types/react-router-config': 5.0.11 clsx: 2.1.1 parse-numeric-range: 1.3.0 @@ -15172,13 +15159,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-mermaid@3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/theme-mermaid@3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) mermaid: 11.16.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -15208,17 +15195,17 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-search-algolia@3.10.2(@algolia/client-search@5.56.0)(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/theme-search-algolia@3.10.2(@algolia/client-search@5.56.0)(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: '@algolia/autocomplete-core': 1.19.9(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3) - '@docsearch/react': 4.6.3(@algolia/client-search@5.56.0)(@types/react@19.2.17)(algoliasearch@5.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3) - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docsearch/react': 4.6.3(@algolia/client-search@5.56.0)(@types/react@19.2.18)(algoliasearch@5.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/logger': 3.10.2 - '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/plugin-content-docs': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) '@docusaurus/theme-translations': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) algoliasearch: 5.56.0 algoliasearch-helper: 3.29.2(algoliasearch@5.56.0) clsx: 2.1.1 @@ -15263,19 +15250,19 @@ snapshots: '@docusaurus/tsconfig@3.10.2': {} - '@docusaurus/types@3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: '@mdx-js/mdx': 3.1.1(supports-color@8.1.1) '@types/history': 4.7.11 '@types/mdast': 4.0.4 - '@types/react': 19.2.17 + '@types/react': 19.2.18 commander: 5.1.0 joi: 17.13.4 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' utility-types: 3.11.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) webpack-merge: 5.10.0 transitivePeerDependencies: - '@minify-html/node' @@ -15293,9 +15280,9 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/utils-common@3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/utils-common@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) tslib: 2.8.1 transitivePeerDependencies: - '@minify-html/node' @@ -15315,11 +15302,11 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/utils-validation@3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/utils-validation@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: '@docusaurus/logger': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) fs-extra: 11.3.6 joi: 17.13.4 js-yaml: 4.3.0 @@ -15343,15 +15330,15 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/utils@3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': + '@docusaurus/utils@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3)': dependencies: '@11ty/gray-matter': 1.0.0 '@docusaurus/logger': 3.10.2 - '@docusaurus/types': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) escape-string-regexp: 4.0.0 execa: 5.1.1 - file-loader: 6.2.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + file-loader: 6.2.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) fs-extra: 11.3.6 github-slugger: 1.5.0 globby: 11.1.0 @@ -15363,9 +15350,9 @@ snapshots: prompts: 2.4.2 resolve-pathname: 3.0.0 tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)))(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)))(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) utility-types: 3.11.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) transitivePeerDependencies: - '@minify-html/node' - '@swc/core' @@ -15384,29 +15371,18 @@ snapshots: - uglify-js - webpack-cli - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.19.12': optional: true '@esbuild/aix-ppc64@0.27.4': optional: true - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true '@esbuild/android-arm64@0.19.12': @@ -15415,7 +15391,7 @@ snapshots: '@esbuild/android-arm64@0.27.4': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true '@esbuild/android-arm@0.19.12': @@ -15424,7 +15400,7 @@ snapshots: '@esbuild/android-arm@0.27.4': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true '@esbuild/android-x64@0.19.12': @@ -15433,7 +15409,7 @@ snapshots: '@esbuild/android-x64@0.27.4': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true '@esbuild/darwin-arm64@0.19.12': @@ -15442,7 +15418,7 @@ snapshots: '@esbuild/darwin-arm64@0.27.4': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true '@esbuild/darwin-x64@0.19.12': @@ -15451,7 +15427,7 @@ snapshots: '@esbuild/darwin-x64@0.27.4': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true '@esbuild/freebsd-arm64@0.19.12': @@ -15460,7 +15436,7 @@ snapshots: '@esbuild/freebsd-arm64@0.27.4': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true '@esbuild/freebsd-x64@0.19.12': @@ -15469,7 +15445,7 @@ snapshots: '@esbuild/freebsd-x64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true '@esbuild/linux-arm64@0.19.12': @@ -15478,7 +15454,7 @@ snapshots: '@esbuild/linux-arm64@0.27.4': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true '@esbuild/linux-arm@0.19.12': @@ -15487,7 +15463,7 @@ snapshots: '@esbuild/linux-arm@0.27.4': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true '@esbuild/linux-ia32@0.19.12': @@ -15496,7 +15472,7 @@ snapshots: '@esbuild/linux-ia32@0.27.4': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true '@esbuild/linux-loong64@0.19.12': @@ -15505,7 +15481,7 @@ snapshots: '@esbuild/linux-loong64@0.27.4': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true '@esbuild/linux-mips64el@0.19.12': @@ -15514,7 +15490,7 @@ snapshots: '@esbuild/linux-mips64el@0.27.4': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true '@esbuild/linux-ppc64@0.19.12': @@ -15523,7 +15499,7 @@ snapshots: '@esbuild/linux-ppc64@0.27.4': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true '@esbuild/linux-riscv64@0.19.12': @@ -15532,7 +15508,7 @@ snapshots: '@esbuild/linux-riscv64@0.27.4': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true '@esbuild/linux-s390x@0.19.12': @@ -15541,7 +15517,7 @@ snapshots: '@esbuild/linux-s390x@0.27.4': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true '@esbuild/linux-x64@0.19.12': @@ -15550,13 +15526,13 @@ snapshots: '@esbuild/linux-x64@0.27.4': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true '@esbuild/netbsd-arm64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true '@esbuild/netbsd-x64@0.19.12': @@ -15565,13 +15541,13 @@ snapshots: '@esbuild/netbsd-x64@0.27.4': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true '@esbuild/openbsd-arm64@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true '@esbuild/openbsd-x64@0.19.12': @@ -15580,13 +15556,13 @@ snapshots: '@esbuild/openbsd-x64@0.27.4': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true '@esbuild/openharmony-arm64@0.27.4': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true '@esbuild/sunos-x64@0.19.12': @@ -15595,7 +15571,7 @@ snapshots: '@esbuild/sunos-x64@0.27.4': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true '@esbuild/win32-arm64@0.19.12': @@ -15604,7 +15580,7 @@ snapshots: '@esbuild/win32-arm64@0.27.4': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true '@esbuild/win32-ia32@0.19.12': @@ -15613,7 +15589,7 @@ snapshots: '@esbuild/win32-ia32@0.27.4': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true '@esbuild/win32-x64@0.19.12': @@ -15622,7 +15598,7 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))': @@ -15707,7 +15683,7 @@ snapshots: '@formatjs/icu-skeleton-parser': 1.8.16 tslib: 2.8.1 - '@formatjs/icu-messageformat-parser@3.5.15': + '@formatjs/icu-messageformat-parser@3.5.16': dependencies: '@formatjs/icu-skeleton-parser': 2.1.11 @@ -15894,18 +15870,19 @@ snapshots: pg-connection-string: 2.14.0 postgres: 3.4.9 - '@immich/ui@0.83.0(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))': + '@immich/ui@0.85.0(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))': dependencies: '@internationalized/date': 3.12.2 '@mdi/js': 7.4.47 - '@sveltejs/kit': 2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) - bits-ui: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + bits-ui: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + fuse.js: 7.5.0 luxon: 3.7.2 - simple-icons: 16.27.1 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + simple-icons: 16.28.0 + svelte: 5.56.8(@typescript-eslint/types@8.66.0) svelte-highlight: 7.11.0 tailwind-merge: 3.6.0 - tailwind-variants: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3) + tailwind-variants: 3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.3) tailwindcss: 4.3.3 '@inquirer/ansi@1.0.2': {} @@ -16261,8 +16238,8 @@ snapshots: '@koddsson/eslint-plugin-tscompat@0.2.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/type-utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) browserslist: 4.28.6 transitivePeerDependencies: - eslint @@ -16395,10 +16372,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8)': + '@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)': dependencies: '@types/mdx': 2.0.14 - '@types/react': 19.2.17 + '@types/react': 19.2.18 react: 19.2.8 '@mermaid-js/parser@1.2.0': @@ -16427,28 +16404,21 @@ snapshots: '@namnode/store@0.1.0': {} - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)': dependencies: '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(bullmq@5.81.2(supports-color@8.1.1))': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(bullmq@5.81.3(supports-color@8.1.1))': dependencies: '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28) '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.81.2(supports-color@8.1.1) + bullmq: 5.81.3(supports-color@8.1.1) tslib: 2.8.1 - '@nestjs/cli@11.0.24(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@24.13.3)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(prettier@3.9.6)(uglify-js@3.19.3)': + '@nestjs/cli@11.0.24(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@24.13.3)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(prettier@3.9.6)(uglify-js@3.19.3)': dependencies: '@angular-devkit/core': 19.2.27(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.27(chokidar@4.0.3) @@ -16459,17 +16429,17 @@ snapshots: chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) glob: 13.0.6 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.106.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) webpack-node-externals: 3.0.0 optionalDependencies: - '@swc/core': 1.15.46(@swc/helpers@0.5.23) + '@swc/core': 1.15.47(@swc/helpers@0.5.23) transitivePeerDependencies: - '@minify-html/node' - '@swc/css' @@ -16610,7 +16580,7 @@ snapshots: '@noble/hashes@1.8.0': {} - '@noble/hashes@2.2.0': {} + '@noble/hashes@2.3.0': {} '@nodelib/fs.scandir@2.1.5': dependencies: @@ -16901,7 +16871,7 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) - '@oxc-project/types@0.139.0': {} + '@oxc-project/types@0.143.0': {} '@paralleldrive/cuid2@2.3.1': dependencies: @@ -17062,32 +17032,32 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 - '@photo-sphere-viewer/core@5.15.0': + '@photo-sphere-viewer/core@5.15.1': dependencies: three: 0.185.1 - '@photo-sphere-viewer/equirectangular-video-adapter@5.15.0(@photo-sphere-viewer/core@5.15.0)(@photo-sphere-viewer/video-plugin@5.15.0(@photo-sphere-viewer/core@5.15.0))': + '@photo-sphere-viewer/equirectangular-video-adapter@5.15.1(@photo-sphere-viewer/core@5.15.1)(@photo-sphere-viewer/video-plugin@5.15.1(@photo-sphere-viewer/core@5.15.1))': dependencies: - '@photo-sphere-viewer/core': 5.15.0 - '@photo-sphere-viewer/video-plugin': 5.15.0(@photo-sphere-viewer/core@5.15.0) + '@photo-sphere-viewer/core': 5.15.1 + '@photo-sphere-viewer/video-plugin': 5.15.1(@photo-sphere-viewer/core@5.15.1) three: 0.185.1 - '@photo-sphere-viewer/markers-plugin@5.15.0(@photo-sphere-viewer/core@5.15.0)': + '@photo-sphere-viewer/markers-plugin@5.15.1(@photo-sphere-viewer/core@5.15.1)': dependencies: - '@photo-sphere-viewer/core': 5.15.0 + '@photo-sphere-viewer/core': 5.15.1 - '@photo-sphere-viewer/resolution-plugin@5.15.0(@photo-sphere-viewer/core@5.15.0)(@photo-sphere-viewer/settings-plugin@5.15.0(@photo-sphere-viewer/core@5.15.0))': + '@photo-sphere-viewer/resolution-plugin@5.15.1(@photo-sphere-viewer/core@5.15.1)(@photo-sphere-viewer/settings-plugin@5.15.1(@photo-sphere-viewer/core@5.15.1))': dependencies: - '@photo-sphere-viewer/core': 5.15.0 - '@photo-sphere-viewer/settings-plugin': 5.15.0(@photo-sphere-viewer/core@5.15.0) + '@photo-sphere-viewer/core': 5.15.1 + '@photo-sphere-viewer/settings-plugin': 5.15.1(@photo-sphere-viewer/core@5.15.1) - '@photo-sphere-viewer/settings-plugin@5.15.0(@photo-sphere-viewer/core@5.15.0)': + '@photo-sphere-viewer/settings-plugin@5.15.1(@photo-sphere-viewer/core@5.15.1)': dependencies: - '@photo-sphere-viewer/core': 5.15.0 + '@photo-sphere-viewer/core': 5.15.1 - '@photo-sphere-viewer/video-plugin@5.15.0(@photo-sphere-viewer/core@5.15.0)': + '@photo-sphere-viewer/video-plugin@5.15.1(@photo-sphere-viewer/core@5.15.1)': dependencies: - '@photo-sphere-viewer/core': 5.15.0 + '@photo-sphere-viewer/core': 5.15.1 three: 0.185.1 '@photostructure/tz-lookup@11.5.0': {} @@ -17097,9 +17067,9 @@ snapshots: '@pkgr/core@0.3.6': {} - '@playwright/test@1.62.0': + '@playwright/test@1.62.1': dependencies: - playwright: 1.62.0 + playwright: 1.62.1 '@pnpm/config.env-replace@1.1.0': {} @@ -17274,53 +17244,46 @@ snapshots: '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.7 - '@rolldown/binding-android-arm64@1.1.5': + '@rolldown/binding-android-arm64@1.2.3': optional: true - '@rolldown/binding-darwin-arm64@1.1.5': + '@rolldown/binding-darwin-arm64@1.2.3': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rolldown/binding-darwin-x64@1.2.3': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rolldown/binding-freebsd-x64@1.2.3': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rolldown/binding-linux-arm64-gnu@1.2.3': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rolldown/binding-linux-arm64-musl@1.2.3': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rolldown/binding-linux-ppc64-gnu@1.2.3': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rolldown/binding-linux-s390x-gnu@1.2.3': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rolldown/binding-linux-x64-gnu@1.2.3': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rolldown/binding-linux-x64-musl@1.2.3': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': + '@rolldown/binding-openharmony-arm64@1.2.3': optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-win32-arm64-msvc@1.2.3': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rolldown/binding-win32-x64-msvc@1.2.3': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -17460,58 +17423,58 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@sveltejs/acorn-typescript@1.0.11(acorn@8.18.0)': + '@sveltejs/acorn-typescript@1.0.12(acorn@8.18.0)': dependencies: acorn: 8.18.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))': dependencies: - '@sveltejs/kit': 2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) - '@sveltejs/enhanced-img@0.11.0(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(rollup@4.62.0)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))': + '@sveltejs/enhanced-img@0.11.0(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(rollup@4.62.0)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': dependencies: - '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte-parse-markup: 0.1.5(svelte@5.56.8(@typescript-eslint/types@8.65.0)) - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte-parse-markup: 0.1.5(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) vite-imagetools: 9.0.3(rollup@4.62.0) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - '@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))': + '@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': dependencies: '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.11(acorn@8.18.0) - '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + '@sveltejs/acorn-typescript': 1.0.12(acorn@8.18.0) + '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@types/cookie': 0.6.0 acorn: 8.18.0 cookie: 0.6.0 - devalue: 5.8.2 + devalue: 5.9.0 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 mrmime: 2.0.1 set-cookie-parser: 3.1.2 sirv: 3.0.2 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) optionalDependencies: '@opentelemetry/api': 1.9.1 typescript: '@typescript/typescript6@6.0.2' - '@sveltejs/load-config@0.2.0': {} + '@sveltejs/load-config@0.2.2': {} - '@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.3 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: @@ -17606,59 +17569,59 @@ snapshots: - supports-color - typescript - '@swc/core-darwin-arm64@1.15.46': + '@swc/core-darwin-arm64@1.15.47': optional: true - '@swc/core-darwin-x64@1.15.46': + '@swc/core-darwin-x64@1.15.47': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.46': + '@swc/core-linux-arm-gnueabihf@1.15.47': optional: true - '@swc/core-linux-arm64-gnu@1.15.46': + '@swc/core-linux-arm64-gnu@1.15.47': optional: true - '@swc/core-linux-arm64-musl@1.15.46': + '@swc/core-linux-arm64-musl@1.15.47': optional: true - '@swc/core-linux-ppc64-gnu@1.15.46': + '@swc/core-linux-ppc64-gnu@1.15.47': optional: true - '@swc/core-linux-s390x-gnu@1.15.46': + '@swc/core-linux-s390x-gnu@1.15.47': optional: true - '@swc/core-linux-x64-gnu@1.15.46': + '@swc/core-linux-x64-gnu@1.15.47': optional: true - '@swc/core-linux-x64-musl@1.15.46': + '@swc/core-linux-x64-musl@1.15.47': optional: true - '@swc/core-win32-arm64-msvc@1.15.46': + '@swc/core-win32-arm64-msvc@1.15.47': optional: true - '@swc/core-win32-ia32-msvc@1.15.46': + '@swc/core-win32-ia32-msvc@1.15.47': optional: true - '@swc/core-win32-x64-msvc@1.15.46': + '@swc/core-win32-x64-msvc@1.15.47': optional: true - '@swc/core@1.15.46(@swc/helpers@0.5.23)': + '@swc/core@1.15.47(@swc/helpers@0.5.23)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.27 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.46 - '@swc/core-darwin-x64': 1.15.46 - '@swc/core-linux-arm-gnueabihf': 1.15.46 - '@swc/core-linux-arm64-gnu': 1.15.46 - '@swc/core-linux-arm64-musl': 1.15.46 - '@swc/core-linux-ppc64-gnu': 1.15.46 - '@swc/core-linux-s390x-gnu': 1.15.46 - '@swc/core-linux-x64-gnu': 1.15.46 - '@swc/core-linux-x64-musl': 1.15.46 - '@swc/core-win32-arm64-msvc': 1.15.46 - '@swc/core-win32-ia32-msvc': 1.15.46 - '@swc/core-win32-x64-msvc': 1.15.46 + '@swc/core-darwin-arm64': 1.15.47 + '@swc/core-darwin-x64': 1.15.47 + '@swc/core-linux-arm-gnueabihf': 1.15.47 + '@swc/core-linux-arm64-gnu': 1.15.47 + '@swc/core-linux-arm64-musl': 1.15.47 + '@swc/core-linux-ppc64-gnu': 1.15.47 + '@swc/core-linux-s390x-gnu': 1.15.47 + '@swc/core-linux-x64-gnu': 1.15.47 + '@swc/core-linux-x64-musl': 1.15.47 + '@swc/core-win32-arm64-msvc': 1.15.47 + '@swc/core-win32-ia32-msvc': 1.15.47 + '@swc/core-win32-x64-msvc': 1.15.47 '@swc/helpers': 0.5.23 '@swc/counter@0.1.3': {} @@ -17736,12 +17699,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) '@testing-library/dom@10.4.1': dependencies: @@ -17764,20 +17727,20 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.1.3(svelte@5.56.8(@typescript-eslint/types@8.65.0))': + '@testing-library/svelte-core@1.1.3(svelte@5.56.8(@typescript-eslint/types@8.66.0))': dependencies: - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) - '@testing-library/svelte@5.4.2(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.10)': + '@testing-library/svelte@5.4.2(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))(vitest@4.1.10)': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.1.3(svelte@5.56.8(@typescript-eslint/types@8.65.0)) - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + '@testing-library/svelte-core': 1.1.3(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.3(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -17790,7 +17753,7 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(prettier@3.9.6)(supports-color@8.1.1)(svelte@5.56.8(@typescript-eslint/types@8.65.0))': + '@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)))(prettier@3.9.6)(supports-color@8.1.1)(svelte@5.56.8(@typescript-eslint/types@8.66.0))': dependencies: '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 @@ -17802,8 +17765,8 @@ snapshots: parse-imports-exports: 0.2.4 prettier: 3.9.6 optionalDependencies: - prettier-plugin-svelte: 4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)) - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + prettier-plugin-svelte: 4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) transitivePeerDependencies: - supports-color @@ -17826,11 +17789,6 @@ snapshots: '@types/geojson': 7946.0.16 tslib: 2.8.1 - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@types/accepts@1.3.7': dependencies: '@types/node': 24.13.3 @@ -18179,11 +18137,11 @@ snapshots: '@types/lodash-es@4.17.12': dependencies: - '@types/lodash': 4.17.24 + '@types/lodash': 4.17.25 - '@types/lodash@4.17.24': {} + '@types/lodash@4.17.25': {} - '@types/luxon@3.7.2': {} + '@types/luxon@3.7.3': {} '@types/mdast@4.0.4': dependencies: @@ -18223,7 +18181,7 @@ snapshots: dependencies: '@types/node': 24.13.3 - '@types/oidc-provider@9.5.0': + '@types/oidc-provider@9.11.0': dependencies: '@types/keygrip': 1.0.6 '@types/koa': 3.0.3 @@ -18233,18 +18191,18 @@ snapshots: '@types/pg-pool@2.0.7': dependencies: - '@types/pg': 8.20.0 + '@types/pg': 8.20.4 '@types/pg@8.15.6': dependencies: '@types/node': 24.13.3 - pg-protocol: 1.15.0 + pg-protocol: 1.16.0 pg-types: 2.2.0 - '@types/pg@8.20.0': + '@types/pg@8.20.4': dependencies: '@types/node': 24.13.3 - pg-protocol: 1.15.0 + pg-protocol: 1.16.0 pg-types: 2.2.0 '@types/picomatch@4.0.3': {} @@ -18266,21 +18224,21 @@ snapshots: '@types/react-router-config@5.0.11': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.17 + '@types/react': 19.2.18 '@types/react-router': 5.1.20 '@types/react-router-dom@5.3.3': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.17 + '@types/react': 19.2.18 '@types/react-router': 5.1.20 '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/react@19.2.17': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 @@ -18294,7 +18252,7 @@ snapshots: dependencies: '@types/node': 24.13.3 - '@types/semver@7.7.1': {} + '@types/semver@7.8.0': {} '@types/send@0.17.6': dependencies: @@ -18375,14 +18333,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/type-utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/visitor-keys': 8.66.0 eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) ignore: 7.0.6 natural-compare: 1.4.0 @@ -18391,41 +18349,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3(supports-color@8.1.1) eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': + '@typescript-eslint/project-service@8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.66.0 debug: 4.4.3(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.65.0': + '@typescript-eslint/scope-manager@8.66.0': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 - '@typescript-eslint/tsconfig-utils@8.65.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.66.0(@typescript/typescript6@6.0.2)': dependencies: typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/type-utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1) eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) @@ -18433,14 +18391,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/types@8.66.0': {} - '@typescript-eslint/typescript-estree@8.65.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': + '@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': dependencies: - '@typescript-eslint/project-service': 8.65.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/tsconfig-utils': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/project-service': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.6 semver: 7.8.5 @@ -18450,20 +18408,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.65.0': + '@typescript-eslint/visitor-keys@8.66.0': dependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.66.0 eslint-visitor-keys: 5.0.1 '@typescript/typescript-aix-ppc64@7.0.2': @@ -18541,7 +18499,7 @@ snapshots: dependencies: valibot: 1.4.2(@typescript/typescript6@6.0.2) - '@vitest/coverage-v8@4.1.10(vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))': + '@vitest/coverage-v8@4.1.10(vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.10 @@ -18553,7 +18511,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vitest: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: @@ -18567,7 +18525,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@vitest/expect@3.2.7': dependencies: @@ -18586,21 +18544,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) '@vitest/pretty-format@3.2.7': dependencies: @@ -18736,10 +18694,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.56.8(@typescript-eslint/types@8.65.0))': + '@zoom-image/svelte@0.3.9(svelte@5.56.8(@typescript-eslint/types@8.66.0))': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) abbrev@1.1.1: {} @@ -18986,25 +18944,25 @@ snapshots: dependencies: immediate: 3.3.0 - autoprefixer@10.5.4(postcss@8.5.25): + autoprefixer@10.5.4(postcss@8.5.26): dependencies: browserslist: 4.28.6 caniuse-lite: 1.0.30001806 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 axobject-query@4.1.0: {} b4a@1.8.1: {} - babel-loader@9.2.1(@babel/core@7.29.7(supports-color@8.1.1))(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + babel-loader@9.2.1(@babel/core@7.29.7(supports-color@8.1.1))(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) find-cache-dir: 4.0.0 schema-utils: 4.3.3 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) babel-plugin-dynamic-import-node@2.3.3: dependencies: @@ -19052,12 +19010,12 @@ snapshots: bare-events@2.9.1: {} - bare-fs@4.7.4: + bare-fs@4.8.0: dependencies: bare-events: 2.9.1 bare-path: 3.1.1 bare-stream: 2.13.3(bare-events@2.9.1) - bare-url: 2.4.5 + bare-url: 2.5.1 fast-fifo: 1.3.2 transitivePeerDependencies: - bare-abort-controller @@ -19075,7 +19033,7 @@ snapshots: transitivePeerDependencies: - react-native-b4a - bare-url@2.4.5: + bare-url@2.5.1: dependencies: bare-path: 3.1.1 @@ -19105,15 +19063,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: '@floating-ui/core': 1.8.0 '@floating-ui/dom': 1.8.0 '@internationalized/date': 3.12.2 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + runed: 0.35.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) tabbable: 6.5.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -19234,11 +19192,11 @@ snapshots: builtin-modules@5.2.0: {} - bullmq@5.81.2(supports-color@8.1.1): + bullmq@5.81.3(supports-color@8.1.1): dependencies: cron-parser: 4.9.0 ioredis: 5.11.1(supports-color@8.1.1) - msgpackr: 2.0.4 + msgpackr: 2.0.5 node-abort-controller: 3.1.1 semver: 7.8.5 tslib: 2.8.1 @@ -19657,7 +19615,7 @@ snapshots: copy-text-to-clipboard@3.2.2: {} - copy-webpack-plugin@11.0.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + copy-webpack-plugin@11.0.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -19665,7 +19623,7 @@ snapshots: normalize-path: 3.0.0 schema-utils: 4.3.3 serialize-javascript: 6.0.2 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) core-js-compat@3.49.0: dependencies: @@ -19727,7 +19685,7 @@ snapshots: cron@4.4.0: dependencies: - '@types/luxon': 3.7.2 + '@types/luxon': 3.7.3 luxon: 3.7.2 cross-spawn@7.0.6: @@ -19740,53 +19698,53 @@ snapshots: dependencies: type-fest: 1.4.0 - css-blank-pseudo@7.0.1(postcss@8.5.25): + css-blank-pseudo@7.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - css-declaration-sorter@7.4.0(postcss@8.5.25): + css-declaration-sorter@7.4.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - css-has-pseudo@7.0.3(postcss@8.5.25): + css-has-pseudo@7.0.3(postcss@8.5.26): dependencies: - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4) - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 postcss-value-parser: 4.2.0 - css-loader@6.11.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + css-loader@6.11.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: - icss-utils: 5.1.0(postcss@8.5.25) - postcss: 8.5.25 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.25) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.25) - postcss-modules-scope: 3.2.1(postcss@8.5.25) - postcss-modules-values: 4.0.0(postcss@8.5.25) + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.26) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.26) + postcss-modules-scope: 3.2.1(postcss@8.5.26) + postcss-modules-values: 4.0.0(postcss@8.5.26) postcss-value-parser: 4.2.0 semver: 7.8.5 optionalDependencies: - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) - css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 - cssnano: 6.1.2(postcss@8.5.25) + cssnano: 6.1.2(postcss@8.5.26) jest-worker: 29.7.0 - postcss: 8.5.25 + postcss: 8.5.26 schema-utils: 4.3.3 serialize-javascript: 6.0.2 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) optionalDependencies: clean-css: 5.3.3 csso: 5.0.5 - esbuild: 0.28.1 + esbuild: 0.28.2 lightningcss: 1.33.0 - css-prefers-color-scheme@10.0.0(postcss@8.5.25): + css-prefers-color-scheme@10.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 css-select@4.3.0: dependencies: @@ -19824,60 +19782,60 @@ snapshots: cssesc@3.0.0: {} - cssnano-preset-advanced@6.1.2(postcss@8.5.25): + cssnano-preset-advanced@6.1.2(postcss@8.5.26): dependencies: - autoprefixer: 10.5.4(postcss@8.5.25) + autoprefixer: 10.5.4(postcss@8.5.26) browserslist: 4.28.6 - cssnano-preset-default: 6.1.2(postcss@8.5.25) - postcss: 8.5.25 - postcss-discard-unused: 6.0.5(postcss@8.5.25) - postcss-merge-idents: 6.0.3(postcss@8.5.25) - postcss-reduce-idents: 6.0.3(postcss@8.5.25) - postcss-zindex: 6.0.2(postcss@8.5.25) + cssnano-preset-default: 6.1.2(postcss@8.5.26) + postcss: 8.5.26 + postcss-discard-unused: 6.0.5(postcss@8.5.26) + postcss-merge-idents: 6.0.3(postcss@8.5.26) + postcss-reduce-idents: 6.0.3(postcss@8.5.26) + postcss-zindex: 6.0.2(postcss@8.5.26) - cssnano-preset-default@6.1.2(postcss@8.5.25): + cssnano-preset-default@6.1.2(postcss@8.5.26): dependencies: browserslist: 4.28.6 - css-declaration-sorter: 7.4.0(postcss@8.5.25) - cssnano-utils: 4.0.2(postcss@8.5.25) - postcss: 8.5.25 - postcss-calc: 9.0.1(postcss@8.5.25) - postcss-colormin: 6.1.0(postcss@8.5.25) - postcss-convert-values: 6.1.0(postcss@8.5.25) - postcss-discard-comments: 6.0.2(postcss@8.5.25) - postcss-discard-duplicates: 6.0.3(postcss@8.5.25) - postcss-discard-empty: 6.0.3(postcss@8.5.25) - postcss-discard-overridden: 6.0.2(postcss@8.5.25) - postcss-merge-longhand: 6.0.5(postcss@8.5.25) - postcss-merge-rules: 6.1.1(postcss@8.5.25) - postcss-minify-font-values: 6.1.0(postcss@8.5.25) - postcss-minify-gradients: 6.0.3(postcss@8.5.25) - postcss-minify-params: 6.1.0(postcss@8.5.25) - postcss-minify-selectors: 6.0.4(postcss@8.5.25) - postcss-normalize-charset: 6.0.2(postcss@8.5.25) - postcss-normalize-display-values: 6.0.2(postcss@8.5.25) - postcss-normalize-positions: 6.0.2(postcss@8.5.25) - postcss-normalize-repeat-style: 6.0.2(postcss@8.5.25) - postcss-normalize-string: 6.0.2(postcss@8.5.25) - postcss-normalize-timing-functions: 6.0.2(postcss@8.5.25) - postcss-normalize-unicode: 6.1.0(postcss@8.5.25) - postcss-normalize-url: 6.0.2(postcss@8.5.25) - postcss-normalize-whitespace: 6.0.2(postcss@8.5.25) - postcss-ordered-values: 6.0.2(postcss@8.5.25) - postcss-reduce-initial: 6.1.0(postcss@8.5.25) - postcss-reduce-transforms: 6.0.2(postcss@8.5.25) - postcss-svgo: 6.0.3(postcss@8.5.25) - postcss-unique-selectors: 6.0.4(postcss@8.5.25) + css-declaration-sorter: 7.4.0(postcss@8.5.26) + cssnano-utils: 4.0.2(postcss@8.5.26) + postcss: 8.5.26 + postcss-calc: 9.0.1(postcss@8.5.26) + postcss-colormin: 6.1.0(postcss@8.5.26) + postcss-convert-values: 6.1.0(postcss@8.5.26) + postcss-discard-comments: 6.0.2(postcss@8.5.26) + postcss-discard-duplicates: 6.0.3(postcss@8.5.26) + postcss-discard-empty: 6.0.3(postcss@8.5.26) + postcss-discard-overridden: 6.0.2(postcss@8.5.26) + postcss-merge-longhand: 6.0.5(postcss@8.5.26) + postcss-merge-rules: 6.1.1(postcss@8.5.26) + postcss-minify-font-values: 6.1.0(postcss@8.5.26) + postcss-minify-gradients: 6.0.3(postcss@8.5.26) + postcss-minify-params: 6.1.0(postcss@8.5.26) + postcss-minify-selectors: 6.0.4(postcss@8.5.26) + postcss-normalize-charset: 6.0.2(postcss@8.5.26) + postcss-normalize-display-values: 6.0.2(postcss@8.5.26) + postcss-normalize-positions: 6.0.2(postcss@8.5.26) + postcss-normalize-repeat-style: 6.0.2(postcss@8.5.26) + postcss-normalize-string: 6.0.2(postcss@8.5.26) + postcss-normalize-timing-functions: 6.0.2(postcss@8.5.26) + postcss-normalize-unicode: 6.1.0(postcss@8.5.26) + postcss-normalize-url: 6.0.2(postcss@8.5.26) + postcss-normalize-whitespace: 6.0.2(postcss@8.5.26) + postcss-ordered-values: 6.0.2(postcss@8.5.26) + postcss-reduce-initial: 6.1.0(postcss@8.5.26) + postcss-reduce-transforms: 6.0.2(postcss@8.5.26) + postcss-svgo: 6.0.3(postcss@8.5.26) + postcss-unique-selectors: 6.0.4(postcss@8.5.26) - cssnano-utils@4.0.2(postcss@8.5.25): + cssnano-utils@4.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - cssnano@6.1.2(postcss@8.5.25): + cssnano@6.1.2(postcss@8.5.26): dependencies: - cssnano-preset-default: 6.1.2(postcss@8.5.25) + cssnano-preset-default: 6.1.2(postcss@8.5.26) lilconfig: 3.1.3 - postcss: 8.5.25 + postcss: 8.5.26 csso@5.0.5: dependencies: @@ -20202,7 +20160,7 @@ snapshots: dependencies: address: 2.0.3 - devalue@5.8.2: {} + devalue@5.9.0: {} devlop@1.1.0: dependencies: @@ -20259,9 +20217,9 @@ snapshots: transitivePeerDependencies: - supports-color - docusaurus-lunr-search@3.6.0(@docusaurus/core@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + docusaurus-lunr-search@3.6.0(@docusaurus/core@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) + '@docusaurus/core': 3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(debug@4.4.3(supports-color@8.1.1))(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@8.1.1)(uglify-js@3.19.3) autocomplete.js: 0.37.1 clsx: 2.1.1 gauge: 3.0.2 @@ -20547,34 +20505,34 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -20630,7 +20588,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) - eslint-plugin-svelte@3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + eslint-plugin-svelte@3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -20638,13 +20596,13 @@ snapshots: esutils: 2.0.3 globals: 16.5.0 known-css-properties: 0.37.0 - postcss: 8.5.25 - postcss-load-config: 3.1.4(postcss@8.5.25) - postcss-safe-parser: 7.0.1(postcss@8.5.25) + postcss: 8.5.26 + postcss-load-config: 3.1.4(postcss@8.5.26) + postcss-safe-parser: 7.0.1(postcss@8.5.26) semver: 7.8.5 - svelte-eslint-parser: 1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + svelte-eslint-parser: 1.8.1(svelte@5.56.8(@typescript-eslint/types@8.66.0)) optionalDependencies: - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) transitivePeerDependencies: - ts-node @@ -20660,7 +20618,7 @@ snapshots: entities: 4.5.0 eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) find-up-simple: 1.0.1 - globals: 17.7.0 + globals: 17.9.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 is-identifier: 1.1.0 @@ -20759,11 +20717,11 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.2.13(@typescript-eslint/types@8.65.0): + esrap@2.2.13(@typescript-eslint/types@8.66.0): dependencies: '@jridgewell/sourcemap-codec': 1.5.5 optionalDependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.66.0 esrecurse@4.3.0: dependencies: @@ -20862,7 +20820,7 @@ snapshots: exiftool-vendored@35.21.0: dependencies: '@photostructure/tz-lookup': 11.5.0 - '@types/luxon': 3.7.2 + '@types/luxon': 3.7.3 batch-cluster: 17.3.1 exiftool-vendored.pl: 13.59.0 he: 1.2.0 @@ -21022,11 +20980,11 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) file-source@0.6.1: dependencies: @@ -21113,7 +21071,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@babel/code-frame': 7.29.7 chalk: 4.1.2 @@ -21128,7 +21086,7 @@ snapshots: semver: 7.8.5 tapable: 2.3.3 typescript: 5.9.3 - webpack: 5.106.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) form-data-encoder@2.1.4: {} @@ -21190,6 +21148,8 @@ snapshots: function-timeout@1.0.2: {} + fuse.js@7.5.0: {} + gauge@3.0.2: dependencies: aproba: 2.1.0 @@ -21310,7 +21270,7 @@ snapshots: globals@16.5.0: {} - globals@17.7.0: {} + globals@17.9.0: {} globalyzer@0.1.0: {} @@ -21567,10 +21527,10 @@ snapshots: hls-video-element@1.5.11: dependencies: custom-media-element: 1.4.6 - hls.js: 1.6.16 + hls.js: 1.6.17 media-tracks: 0.3.5 - hls.js@1.6.16: {} + hls.js@1.6.17: {} hogan.js@3.0.2: dependencies: @@ -21627,7 +21587,7 @@ snapshots: html-void-elements@3.0.0: {} - html-webpack-plugin@5.6.7(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + html-webpack-plugin@5.6.7(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -21635,7 +21595,7 @@ snapshots: pretty-error: 4.0.0 tapable: 2.3.3 optionalDependencies: - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) html5parser@3.0.0: {} @@ -21764,9 +21724,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.25): + icss-utils@5.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 identifier-regex@1.1.0: dependencies: @@ -21856,10 +21816,10 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.11.4 tslib: 2.8.1 - intl-messageformat@11.2.12: + intl-messageformat@11.2.13: dependencies: '@formatjs/fast-memoize': 3.1.7 - '@formatjs/icu-messageformat-parser': 3.5.15 + '@formatjs/icu-messageformat-parser': 3.5.16 invariant@2.2.4: dependencies: @@ -22074,7 +22034,7 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 - jose@6.2.4: {} + jose@6.2.8: {} js-tokens@10.0.0: {} @@ -22090,7 +22050,7 @@ snapshots: dependencies: argparse: 2.0.1 - js-yaml@5.2.2: + js-yaml@5.2.3: dependencies: argparse: 2.0.1 @@ -23176,11 +23136,11 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.10.2(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + mini-css-extract-plugin@2.10.2(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: schema-utils: 4.3.3 tapable: 2.3.3 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) minimalistic-assert@1.0.1: {} @@ -23202,22 +23162,22 @@ snapshots: minimist@1.2.8: {} - minimizer-webpack-plugin@5.6.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.49.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) optionalDependencies: - '@swc/core': 1.15.46(@swc/helpers@0.5.23) + '@swc/core': 1.15.47(@swc/helpers@0.5.23) clean-css: 5.3.3 - cssnano: 6.1.2(postcss@8.5.25) + cssnano: 6.1.2(postcss@8.5.26) csso: 5.0.5 - esbuild: 0.28.1 + esbuild: 0.28.2 html-minifier-terser: 7.2.0 lightningcss: 1.33.0 - postcss: 8.5.25 + postcss: 8.5.26 uglify-js: 3.19.3 minipass@3.3.6: @@ -23275,7 +23235,7 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 optional: true - msgpackr@2.0.4: + msgpackr@2.0.5: optionalDependencies: msgpackr-extract: 3.0.4 @@ -23308,9 +23268,9 @@ snapshots: nan@2.28.0: optional: true - nanoid@3.3.16: {} + nanoid@3.3.18: {} - nanoid@6.0.0: {} + nanoid@6.0.1: {} napi-build-utils@2.0.0: optional: true @@ -23439,7 +23399,7 @@ snapshots: node-releases@2.0.51: {} - nodemailer@9.0.3: {} + nodemailer@9.0.4: {} nopt@1.0.10: dependencies: @@ -23478,11 +23438,11 @@ snapshots: dependencies: boolbase: 1.0.0 - null-loader@4.0.1(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + null-loader@4.0.1(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) nwsapi@2.2.24: optional: true @@ -23518,16 +23478,16 @@ snapshots: obug@2.1.3: {} - oidc-provider@9.10.0(supports-color@8.1.1): + oidc-provider@9.11.2(supports-color@8.1.1): dependencies: '@koa/cors': 5.0.0 '@koa/router': 15.7.0(koa@3.2.1)(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1) eta: 4.6.0 - jose: 6.2.4 + jose: 6.2.8 jsesc: 3.1.0 koa: 3.2.1 - nanoid: 6.0.0 + nanoid: 6.0.1 quick-lru: 7.3.0 raw-body: 4.0.0 transitivePeerDependencies: @@ -23577,7 +23537,7 @@ snapshots: openid-client@6.8.4: dependencies: - jose: 6.2.4 + jose: 6.2.8 oauth4webapi: 3.8.6 optionator@0.9.4: @@ -23807,7 +23767,7 @@ snapshots: dependencies: pg: 8.22.0 - pg-protocol@1.15.0: {} + pg-protocol@1.16.0: {} pg-types@2.2.0: dependencies: @@ -23821,7 +23781,7 @@ snapshots: dependencies: pg-connection-string: 2.14.0 pg-pool: 3.14.0(pg@8.22.0) - pg-protocol: 1.15.0 + pg-protocol: 1.16.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: @@ -23856,11 +23816,11 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 - playwright-core@1.62.0: {} + playwright-core@1.62.1: {} - playwright@1.62.0: + playwright@1.62.1: dependencies: - playwright-core: 1.62.0 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 @@ -23894,485 +23854,485 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - postcss-attribute-case-insensitive@7.0.1(postcss@8.5.25): + postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - postcss-calc@9.0.1(postcss@8.5.25): + postcss-calc@9.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 6.1.4 postcss-value-parser: 4.2.0 - postcss-clamp@4.1.0(postcss@8.5.25): + postcss-clamp@4.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-color-functional-notation@7.0.12(postcss@8.5.25): + postcss-color-functional-notation@7.0.12(postcss@8.5.26): dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - postcss-color-hex-alpha@10.0.0(postcss@8.5.25): + postcss-color-hex-alpha@10.0.0(postcss@8.5.26): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-color-rebeccapurple@10.0.0(postcss@8.5.25): + postcss-color-rebeccapurple@10.0.0(postcss@8.5.26): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-colormin@6.1.0(postcss@8.5.25): + postcss-colormin@6.1.0(postcss@8.5.26): dependencies: browserslist: 4.28.6 caniuse-api: 3.0.0 colord: 2.9.3 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-convert-values@6.1.0(postcss@8.5.25): + postcss-convert-values@6.1.0(postcss@8.5.26): dependencies: browserslist: 4.28.6 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-custom-media@11.0.6(postcss@8.5.25): + postcss-custom-media@11.0.6(postcss@8.5.26): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.25 + postcss: 8.5.26 - postcss-custom-properties@14.0.6(postcss@8.5.25): + postcss-custom-properties@14.0.6(postcss@8.5.26): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-custom-selectors@8.0.5(postcss@8.5.25): + postcss-custom-selectors@8.0.5(postcss@8.5.26): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - postcss-dir-pseudo-class@9.0.1(postcss@8.5.25): + postcss-dir-pseudo-class@9.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - postcss-discard-comments@6.0.2(postcss@8.5.25): + postcss-discard-comments@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-discard-duplicates@6.0.3(postcss@8.5.25): + postcss-discard-duplicates@6.0.3(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-discard-empty@6.0.3(postcss@8.5.25): + postcss-discard-empty@6.0.3(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-discard-overridden@6.0.2(postcss@8.5.25): + postcss-discard-overridden@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-discard-unused@6.0.5(postcss@8.5.25): + postcss-discard-unused@6.0.5(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 6.1.4 - postcss-double-position-gradients@6.0.4(postcss@8.5.25): + postcss-double-position-gradients@6.0.4(postcss@8.5.26): dependencies: - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-focus-visible@10.0.1(postcss@8.5.25): + postcss-focus-visible@10.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - postcss-focus-within@9.0.1(postcss@8.5.25): + postcss-focus-within@9.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - postcss-font-variant@5.0.0(postcss@8.5.25): + postcss-font-variant@5.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-gap-properties@6.0.0(postcss@8.5.25): + postcss-gap-properties@6.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-image-set-function@7.0.0(postcss@8.5.25): + postcss-image-set-function@7.0.0(postcss@8.5.26): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-import@15.1.0(postcss@8.5.25): + postcss-import@15.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.12 - postcss-js@4.1.0(postcss@8.5.25): + postcss-js@4.1.0(postcss@8.5.26): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.25 + postcss: 8.5.26 - postcss-lab-function@7.0.12(postcss@8.5.25): + postcss-lab-function@7.0.12(postcss@8.5.26): dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - postcss-load-config@3.1.4(postcss@8.5.25): + postcss-load-config@3.1.4(postcss@8.5.26): dependencies: lilconfig: 2.1.0 yaml: 1.10.3 optionalDependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.25)(tsx@4.23.1)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.9)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.25 - tsx: 4.23.1 + postcss: 8.5.26 + tsx: 4.23.9 yaml: 2.9.0 - postcss-loader@7.3.4(@typescript/typescript6@6.0.2)(postcss@8.5.25)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + postcss-loader@7.3.4(@typescript/typescript6@6.0.2)(postcss@8.5.26)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: cosmiconfig: 8.3.6(@typescript/typescript6@6.0.2) jiti: 1.21.7 - postcss: 8.5.25 + postcss: 8.5.26 semver: 7.8.5 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) transitivePeerDependencies: - typescript - postcss-logical@8.1.0(postcss@8.5.25): + postcss-logical@8.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-merge-idents@6.0.3(postcss@8.5.25): + postcss-merge-idents@6.0.3(postcss@8.5.26): dependencies: - cssnano-utils: 4.0.2(postcss@8.5.25) - postcss: 8.5.25 + cssnano-utils: 4.0.2(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-merge-longhand@6.0.5(postcss@8.5.25): + postcss-merge-longhand@6.0.5(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - stylehacks: 6.1.1(postcss@8.5.25) + stylehacks: 6.1.1(postcss@8.5.26) - postcss-merge-rules@6.1.1(postcss@8.5.25): + postcss-merge-rules@6.1.1(postcss@8.5.26): dependencies: browserslist: 4.28.6 caniuse-api: 3.0.0 - cssnano-utils: 4.0.2(postcss@8.5.25) - postcss: 8.5.25 + cssnano-utils: 4.0.2(postcss@8.5.26) + postcss: 8.5.26 postcss-selector-parser: 6.1.4 - postcss-minify-font-values@6.1.0(postcss@8.5.25): + postcss-minify-font-values@6.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-minify-gradients@6.0.3(postcss@8.5.25): + postcss-minify-gradients@6.0.3(postcss@8.5.26): dependencies: colord: 2.9.3 - cssnano-utils: 4.0.2(postcss@8.5.25) - postcss: 8.5.25 + cssnano-utils: 4.0.2(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-minify-params@6.1.0(postcss@8.5.25): + postcss-minify-params@6.1.0(postcss@8.5.26): dependencies: browserslist: 4.28.6 - cssnano-utils: 4.0.2(postcss@8.5.25) - postcss: 8.5.25 + cssnano-utils: 4.0.2(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-minify-selectors@6.0.4(postcss@8.5.25): + postcss-minify-selectors@6.0.4(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 6.1.4 - postcss-modules-extract-imports@3.1.0(postcss@8.5.25): + postcss-modules-extract-imports@3.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-modules-local-by-default@4.2.0(postcss@8.5.25): + postcss-modules-local-by-default@4.2.0(postcss@8.5.26): dependencies: - icss-utils: 5.1.0(postcss@8.5.25) - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.1(postcss@8.5.25): + postcss-modules-scope@3.2.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - postcss-modules-values@4.0.0(postcss@8.5.25): + postcss-modules-values@4.0.0(postcss@8.5.26): dependencies: - icss-utils: 5.1.0(postcss@8.5.25) - postcss: 8.5.25 + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 - postcss-nested@6.2.0(postcss@8.5.25): + postcss-nested@6.2.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 6.1.4 - postcss-nesting@13.0.2(postcss@8.5.25): + postcss-nesting@13.0.2(postcss@8.5.26): dependencies: - '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.4) - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4) - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.5) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - postcss-normalize-charset@6.0.2(postcss@8.5.25): + postcss-normalize-charset@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-normalize-display-values@6.0.2(postcss@8.5.25): + postcss-normalize-display-values@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-positions@6.0.2(postcss@8.5.25): + postcss-normalize-positions@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@6.0.2(postcss@8.5.25): + postcss-normalize-repeat-style@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-string@6.0.2(postcss@8.5.25): + postcss-normalize-string@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@6.0.2(postcss@8.5.25): + postcss-normalize-timing-functions@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@6.1.0(postcss@8.5.25): + postcss-normalize-unicode@6.1.0(postcss@8.5.26): dependencies: browserslist: 4.28.6 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-url@6.0.2(postcss@8.5.25): + postcss-normalize-url@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@6.0.2(postcss@8.5.25): + postcss-normalize-whitespace@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-opacity-percentage@3.0.0(postcss@8.5.25): + postcss-opacity-percentage@3.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-ordered-values@6.0.2(postcss@8.5.25): + postcss-ordered-values@6.0.2(postcss@8.5.26): dependencies: - cssnano-utils: 4.0.2(postcss@8.5.25) - postcss: 8.5.25 + cssnano-utils: 4.0.2(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-overflow-shorthand@6.0.0(postcss@8.5.25): + postcss-overflow-shorthand@6.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-page-break@3.0.4(postcss@8.5.25): + postcss-page-break@3.0.4(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-place@10.0.0(postcss@8.5.25): + postcss-place@10.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-preset-env@10.6.1(postcss@8.5.25): + postcss-preset-env@10.6.1(postcss@8.5.26): dependencies: - '@csstools/postcss-alpha-function': 1.0.1(postcss@8.5.25) - '@csstools/postcss-cascade-layers': 5.0.2(postcss@8.5.25) - '@csstools/postcss-color-function': 4.0.12(postcss@8.5.25) - '@csstools/postcss-color-function-display-p3-linear': 1.0.1(postcss@8.5.25) - '@csstools/postcss-color-mix-function': 3.0.12(postcss@8.5.25) - '@csstools/postcss-color-mix-variadic-function-arguments': 1.0.2(postcss@8.5.25) - '@csstools/postcss-content-alt-text': 2.0.8(postcss@8.5.25) - '@csstools/postcss-contrast-color-function': 2.0.12(postcss@8.5.25) - '@csstools/postcss-exponential-functions': 2.0.9(postcss@8.5.25) - '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.5.25) - '@csstools/postcss-gamut-mapping': 2.0.11(postcss@8.5.25) - '@csstools/postcss-gradients-interpolation-method': 5.0.12(postcss@8.5.25) - '@csstools/postcss-hwb-function': 4.0.12(postcss@8.5.25) - '@csstools/postcss-ic-unit': 4.0.4(postcss@8.5.25) - '@csstools/postcss-initial': 2.0.1(postcss@8.5.25) - '@csstools/postcss-is-pseudo-class': 5.0.3(postcss@8.5.25) - '@csstools/postcss-light-dark-function': 2.0.11(postcss@8.5.25) - '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.5.25) - '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.5.25) - '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.5.25) - '@csstools/postcss-logical-resize': 3.0.0(postcss@8.5.25) - '@csstools/postcss-logical-viewport-units': 3.0.4(postcss@8.5.25) - '@csstools/postcss-media-minmax': 2.0.9(postcss@8.5.25) - '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.5(postcss@8.5.25) - '@csstools/postcss-nested-calc': 4.0.0(postcss@8.5.25) - '@csstools/postcss-normalize-display-values': 4.0.1(postcss@8.5.25) - '@csstools/postcss-oklab-function': 4.0.12(postcss@8.5.25) - '@csstools/postcss-position-area-property': 1.0.0(postcss@8.5.25) - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/postcss-property-rule-prelude-list': 1.0.0(postcss@8.5.25) - '@csstools/postcss-random-function': 2.0.1(postcss@8.5.25) - '@csstools/postcss-relative-color-syntax': 3.0.12(postcss@8.5.25) - '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.5.25) - '@csstools/postcss-sign-functions': 1.1.4(postcss@8.5.25) - '@csstools/postcss-stepped-value-functions': 4.0.9(postcss@8.5.25) - '@csstools/postcss-syntax-descriptor-syntax-production': 1.0.1(postcss@8.5.25) - '@csstools/postcss-system-ui-font-family': 1.0.0(postcss@8.5.25) - '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.25) - '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.25) - '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.25) - autoprefixer: 10.5.4(postcss@8.5.25) + '@csstools/postcss-alpha-function': 1.0.1(postcss@8.5.26) + '@csstools/postcss-cascade-layers': 5.0.2(postcss@8.5.26) + '@csstools/postcss-color-function': 4.0.12(postcss@8.5.26) + '@csstools/postcss-color-function-display-p3-linear': 1.0.1(postcss@8.5.26) + '@csstools/postcss-color-mix-function': 3.0.12(postcss@8.5.26) + '@csstools/postcss-color-mix-variadic-function-arguments': 1.0.2(postcss@8.5.26) + '@csstools/postcss-content-alt-text': 2.0.8(postcss@8.5.26) + '@csstools/postcss-contrast-color-function': 2.0.12(postcss@8.5.26) + '@csstools/postcss-exponential-functions': 2.0.9(postcss@8.5.26) + '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.5.26) + '@csstools/postcss-gamut-mapping': 2.0.11(postcss@8.5.26) + '@csstools/postcss-gradients-interpolation-method': 5.0.12(postcss@8.5.26) + '@csstools/postcss-hwb-function': 4.0.12(postcss@8.5.26) + '@csstools/postcss-ic-unit': 4.0.4(postcss@8.5.26) + '@csstools/postcss-initial': 2.0.1(postcss@8.5.26) + '@csstools/postcss-is-pseudo-class': 5.0.3(postcss@8.5.26) + '@csstools/postcss-light-dark-function': 2.0.11(postcss@8.5.26) + '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.5.26) + '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.5.26) + '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.5.26) + '@csstools/postcss-logical-resize': 3.0.0(postcss@8.5.26) + '@csstools/postcss-logical-viewport-units': 3.0.4(postcss@8.5.26) + '@csstools/postcss-media-minmax': 2.0.9(postcss@8.5.26) + '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.5(postcss@8.5.26) + '@csstools/postcss-nested-calc': 4.0.0(postcss@8.5.26) + '@csstools/postcss-normalize-display-values': 4.0.1(postcss@8.5.26) + '@csstools/postcss-oklab-function': 4.0.12(postcss@8.5.26) + '@csstools/postcss-position-area-property': 1.0.0(postcss@8.5.26) + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/postcss-property-rule-prelude-list': 1.0.0(postcss@8.5.26) + '@csstools/postcss-random-function': 2.0.1(postcss@8.5.26) + '@csstools/postcss-relative-color-syntax': 3.0.12(postcss@8.5.26) + '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.5.26) + '@csstools/postcss-sign-functions': 1.1.4(postcss@8.5.26) + '@csstools/postcss-stepped-value-functions': 4.0.9(postcss@8.5.26) + '@csstools/postcss-syntax-descriptor-syntax-production': 1.0.1(postcss@8.5.26) + '@csstools/postcss-system-ui-font-family': 1.0.0(postcss@8.5.26) + '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.26) + '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.26) + '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.26) + autoprefixer: 10.5.4(postcss@8.5.26) browserslist: 4.28.6 - css-blank-pseudo: 7.0.1(postcss@8.5.25) - css-has-pseudo: 7.0.3(postcss@8.5.25) - css-prefers-color-scheme: 10.0.0(postcss@8.5.25) + css-blank-pseudo: 7.0.1(postcss@8.5.26) + css-has-pseudo: 7.0.3(postcss@8.5.26) + css-prefers-color-scheme: 10.0.0(postcss@8.5.26) cssdb: 8.9.0 - postcss: 8.5.25 - postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.25) - postcss-clamp: 4.1.0(postcss@8.5.25) - postcss-color-functional-notation: 7.0.12(postcss@8.5.25) - postcss-color-hex-alpha: 10.0.0(postcss@8.5.25) - postcss-color-rebeccapurple: 10.0.0(postcss@8.5.25) - postcss-custom-media: 11.0.6(postcss@8.5.25) - postcss-custom-properties: 14.0.6(postcss@8.5.25) - postcss-custom-selectors: 8.0.5(postcss@8.5.25) - postcss-dir-pseudo-class: 9.0.1(postcss@8.5.25) - postcss-double-position-gradients: 6.0.4(postcss@8.5.25) - postcss-focus-visible: 10.0.1(postcss@8.5.25) - postcss-focus-within: 9.0.1(postcss@8.5.25) - postcss-font-variant: 5.0.0(postcss@8.5.25) - postcss-gap-properties: 6.0.0(postcss@8.5.25) - postcss-image-set-function: 7.0.0(postcss@8.5.25) - postcss-lab-function: 7.0.12(postcss@8.5.25) - postcss-logical: 8.1.0(postcss@8.5.25) - postcss-nesting: 13.0.2(postcss@8.5.25) - postcss-opacity-percentage: 3.0.0(postcss@8.5.25) - postcss-overflow-shorthand: 6.0.0(postcss@8.5.25) - postcss-page-break: 3.0.4(postcss@8.5.25) - postcss-place: 10.0.0(postcss@8.5.25) - postcss-pseudo-class-any-link: 10.0.1(postcss@8.5.25) - postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.25) - postcss-selector-not: 8.0.1(postcss@8.5.25) + postcss: 8.5.26 + postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.26) + postcss-clamp: 4.1.0(postcss@8.5.26) + postcss-color-functional-notation: 7.0.12(postcss@8.5.26) + postcss-color-hex-alpha: 10.0.0(postcss@8.5.26) + postcss-color-rebeccapurple: 10.0.0(postcss@8.5.26) + postcss-custom-media: 11.0.6(postcss@8.5.26) + postcss-custom-properties: 14.0.6(postcss@8.5.26) + postcss-custom-selectors: 8.0.5(postcss@8.5.26) + postcss-dir-pseudo-class: 9.0.1(postcss@8.5.26) + postcss-double-position-gradients: 6.0.4(postcss@8.5.26) + postcss-focus-visible: 10.0.1(postcss@8.5.26) + postcss-focus-within: 9.0.1(postcss@8.5.26) + postcss-font-variant: 5.0.0(postcss@8.5.26) + postcss-gap-properties: 6.0.0(postcss@8.5.26) + postcss-image-set-function: 7.0.0(postcss@8.5.26) + postcss-lab-function: 7.0.12(postcss@8.5.26) + postcss-logical: 8.1.0(postcss@8.5.26) + postcss-nesting: 13.0.2(postcss@8.5.26) + postcss-opacity-percentage: 3.0.0(postcss@8.5.26) + postcss-overflow-shorthand: 6.0.0(postcss@8.5.26) + postcss-page-break: 3.0.4(postcss@8.5.26) + postcss-place: 10.0.0(postcss@8.5.26) + postcss-pseudo-class-any-link: 10.0.1(postcss@8.5.26) + postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.26) + postcss-selector-not: 8.0.1(postcss@8.5.26) - postcss-pseudo-class-any-link@10.0.1(postcss@8.5.25): + postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - postcss-reduce-idents@6.0.3(postcss@8.5.25): + postcss-reduce-idents@6.0.3(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-reduce-initial@6.1.0(postcss@8.5.25): + postcss-reduce-initial@6.1.0(postcss@8.5.26): dependencies: browserslist: 4.28.6 caniuse-api: 3.0.0 - postcss: 8.5.25 + postcss: 8.5.26 - postcss-reduce-transforms@6.0.2(postcss@8.5.25): + postcss-reduce-transforms@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-replace-overflow-wrap@4.0.0(postcss@8.5.25): + postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-safe-parser@7.0.1(postcss@8.5.25): + postcss-safe-parser@7.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-scss@4.0.9(postcss@8.5.25): + postcss-scss@4.0.9(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-selector-not@8.0.1(postcss@8.5.25): + postcss-selector-not@8.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 postcss-selector-parser@6.1.4: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-selector-parser@7.1.4: + postcss-selector-parser@7.1.5: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-sort-media-queries@5.2.0(postcss@8.5.25): + postcss-sort-media-queries@5.2.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 sort-css-media-queries: 2.2.0 - postcss-svgo@6.0.3(postcss@8.5.25): + postcss-svgo@6.0.3(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 svgo: 3.3.4 - postcss-unique-selectors@6.0.4(postcss@8.5.25): + postcss-unique-selectors@6.0.4(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 6.1.4 postcss-value-parser@4.2.0: {} - postcss-zindex@6.0.2(postcss@8.5.25): + postcss-zindex@6.0.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss@8.5.25: + postcss@8.5.26: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -24423,10 +24383,10 @@ snapshots: dependencies: prettier: 3.9.6 - prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: prettier: 3.9.6 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) prettier@3.9.6: {} @@ -24588,11 +24548,11 @@ snapshots: bytes: 3.1.2 http-errors: 2.0.1 - raw-loader@4.0.2(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + raw-loader@4.0.2(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) rc@1.2.8: dependencies: @@ -24640,11 +24600,11 @@ snapshots: dependencies: react: 19.2.8 - react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.8))(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.8))(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@babel/runtime': 7.29.7 react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.8)' - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) react-router-config@5.1.1(react-router@5.3.4(react@19.2.8))(react@19.2.8): dependencies: @@ -24953,35 +24913,34 @@ snapshots: robust-predicates@3.0.3: {} - rolldown@1.1.5: + rolldown@1.2.3: dependencies: - '@oxc-project/types': 0.139.0 + '@oxc-project/types': 0.143.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 + '@rolldown/binding-android-arm64': 1.2.3 + '@rolldown/binding-darwin-arm64': 1.2.3 + '@rolldown/binding-darwin-x64': 1.2.3 + '@rolldown/binding-freebsd-x64': 1.2.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.3 + '@rolldown/binding-linux-arm64-gnu': 1.2.3 + '@rolldown/binding-linux-arm64-musl': 1.2.3 + '@rolldown/binding-linux-ppc64-gnu': 1.2.3 + '@rolldown/binding-linux-s390x-gnu': 1.2.3 + '@rolldown/binding-linux-x64-gnu': 1.2.3 + '@rolldown/binding-linux-x64-musl': 1.2.3 + '@rolldown/binding-openharmony-arm64': 1.2.3 + '@rolldown/binding-win32-arm64-msvc': 1.2.3 + '@rolldown/binding-win32-x64-msvc': 1.2.3 - rollup-plugin-visualizer@7.0.1(rolldown@1.1.5)(rollup@4.62.0): + rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.0): dependencies: open: 11.0.0 picomatch: 4.0.5 source-map: 0.7.6 yargs: 18.0.0 optionalDependencies: - rolldown: 1.1.5 + rolldown: 1.2.3 rollup: 4.62.0 rollup@4.62.0: @@ -25039,7 +24998,7 @@ snapshots: dependencies: escalade: 3.2.0 picocolors: 1.1.1 - postcss: 8.5.25 + postcss: 8.5.26 strip-json-comments: 3.1.1 run-applescript@7.1.0: {} @@ -25050,14 +25009,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + runed@0.35.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) optionalDependencies: - '@sveltejs/kit': 2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) rw@1.3.3: {} @@ -25334,7 +25293,7 @@ snapshots: simple-concat: 1.0.1 optional: true - simple-icons@16.27.1: {} + simple-icons@16.28.0: {} sirv@2.0.4: dependencies: @@ -25595,10 +25554,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylehacks@6.1.1(postcss@8.5.25): + stylehacks@6.1.1(postcss@8.5.26): dependencies: browserslist: 4.28.6 - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 6.1.4 stylis@4.4.0: {} @@ -25651,34 +25610,34 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + svelte-awesome@3.3.5(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) - svelte-check@4.7.3(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + svelte-check@4.7.4(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@sveltejs/load-config': 0.2.0 + '@sveltejs/load-config': 0.2.2 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.5) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + svelte-eslint-parser@1.8.1(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - postcss: 8.5.25 - postcss-scss: 4.0.9(postcss@8.5.25) - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-scss: 4.0.9(postcss@8.5.26) + postcss-selector-parser: 7.1.5 semver: 7.8.5 optionalDependencies: - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) svelte-floating-ui@1.5.8: dependencies: @@ -25691,7 +25650,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + svelte-i18n@4.0.1(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -25699,10 +25658,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) tiny-glob: 0.2.9 - svelte-jsoneditor@3.13.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + svelte-jsoneditor@3.13.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/commands': 6.10.4 @@ -25729,55 +25688,55 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.102.0 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte-awesome: 3.3.5(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte-awesome: 3.3.5(svelte@5.56.8(@typescript-eslint/types@8.66.0)) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.3.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + svelte-maplibre@1.3.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 maplibre-gl: 5.24.0 pmtiles: 3.2.1 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) - svelte-parse-markup@0.1.5(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + svelte-parse-markup@0.1.5(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) - svelte-persisted-store@0.12.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + svelte-persisted-store@0.12.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + runed: 0.35.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) style-to-object: 1.0.14 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.56.8(@typescript-eslint/types@8.65.0): + svelte@5.56.8(@typescript-eslint/types@8.66.0): dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.11(acorn@8.18.0) + '@sveltejs/acorn-typescript': 1.0.12(acorn@8.18.0) '@types/estree': 1.0.9 '@types/trusted-types': 2.0.7 acorn: 8.18.0 aria-query: 5.3.1 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.8.2 + devalue: 5.9.0 esm-env: 1.2.2 - esrap: 2.2.13(@typescript-eslint/types@8.65.0) + esrap: 2.2.13(@typescript-eslint/types@8.66.0) is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 @@ -25820,27 +25779,26 @@ snapshots: tailwind-merge@3.6.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3): - dependencies: - tailwindcss: 4.3.3 + tailwind-variants@3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.3): optionalDependencies: tailwind-merge: 3.6.0 + tailwindcss: 4.3.3 - tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0)): + tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)): dependencies: - tailwindcss: 3.4.19(tsx@4.23.1)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.9)(yaml@2.9.0) - tailwindcss-mso@2.0.3(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0)): + tailwindcss-mso@2.0.3(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)): dependencies: - tailwindcss: 3.4.19(tsx@4.23.1)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.9)(yaml@2.9.0) - tailwindcss-preset-email@1.4.1(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0)): + tailwindcss-preset-email@1.4.1(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)): dependencies: - tailwindcss: 3.4.19(tsx@4.23.1)(yaml@2.9.0) - tailwindcss-email-variants: 3.0.5(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0)) - tailwindcss-mso: 2.0.3(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0)) + tailwindcss: 3.4.19(tsx@4.23.9)(yaml@2.9.0) + tailwindcss-email-variants: 3.0.5(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)) + tailwindcss-mso: 2.0.3(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)) - tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0): + tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -25856,11 +25814,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.25 - postcss-import: 15.1.0(postcss@8.5.25) - postcss-js: 4.1.0(postcss@8.5.25) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.25)(tsx@4.23.1)(yaml@2.9.0) - postcss-nested: 6.2.0(postcss@8.5.25) + postcss: 8.5.26 + postcss-import: 15.1.0(postcss@8.5.26) + postcss-js: 4.1.0(postcss@8.5.26) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.9)(yaml@2.9.0) + postcss-nested: 6.2.0(postcss@8.5.26) postcss-selector-parser: 6.1.4 resolve: 1.22.12 sucrase: 3.35.1 @@ -25884,7 +25842,7 @@ snapshots: pump: 3.0.4 tar-stream: 3.2.0 optionalDependencies: - bare-fs: 4.7.4 + bare-fs: 4.8.0 bare-path: 3.1.1 transitivePeerDependencies: - bare-abort-controller @@ -25902,7 +25860,7 @@ snapshots: tar-stream@3.2.0: dependencies: b4a: 1.8.1 - bare-fs: 4.7.4 + bare-fs: 4.8.0 fast-fifo: 1.3.2 streamx: 2.28.0 transitivePeerDependencies: @@ -25934,40 +25892,40 @@ snapshots: - bare-abort-controller - react-native-b4a - terser-webpack-plugin@5.6.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)(webpack@5.106.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + terser-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)(webpack@5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.49.0 - webpack: 5.106.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) optionalDependencies: - '@swc/core': 1.15.46(@swc/helpers@0.5.23) + '@swc/core': 1.15.47(@swc/helpers@0.5.23) clean-css: 5.3.3 - cssnano: 6.1.2(postcss@8.5.25) + cssnano: 6.1.2(postcss@8.5.26) csso: 5.0.5 - esbuild: 0.28.1 + esbuild: 0.28.2 html-minifier-terser: 7.2.0 lightningcss: 1.33.0 - postcss: 8.5.25 + postcss: 8.5.26 uglify-js: 3.19.3 - terser-webpack-plugin@5.6.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + terser-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.49.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) optionalDependencies: - '@swc/core': 1.15.46(@swc/helpers@0.5.23) + '@swc/core': 1.15.47(@swc/helpers@0.5.23) clean-css: 5.3.3 - cssnano: 6.1.2(postcss@8.5.25) + cssnano: 6.1.2(postcss@8.5.26) csso: 5.0.5 - esbuild: 0.28.1 + esbuild: 0.28.2 html-minifier-terser: 7.2.0 lightningcss: 1.33.0 - postcss: 8.5.25 + postcss: 8.5.26 uglify-js: 3.19.3 terser@5.49.0: @@ -25977,7 +25935,7 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - testcontainers@12.0.4(supports-color@8.1.1): + testcontainers@12.1.0(supports-color@8.1.1): dependencies: '@balena/dockerignore': 1.0.2 '@types/dockerode': 4.0.1 @@ -25993,7 +25951,7 @@ snapshots: ssh-remote-port-forward: 1.0.4 tar-fs: 3.1.3 tmp: 0.2.7 - undici: 8.7.0 + undici: 8.10.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -26165,9 +26123,9 @@ snapshots: tsscmp@1.0.6: {} - tsx@4.23.1: + tsx@4.23.9: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -26217,12 +26175,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): + typescript-eslint@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/parser': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: @@ -26280,7 +26238,7 @@ snapshots: undici@6.26.0: {} - undici@8.7.0: {} + undici@8.10.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -26371,10 +26329,10 @@ snapshots: unpipe@1.0.0: {} - unplugin-swc@1.5.9(@swc/core@1.15.46(@swc/helpers@0.5.23))(rollup@4.62.0): + unplugin-swc@1.5.10(@swc/core@1.15.47(@swc/helpers@0.5.23))(rollup@4.62.0): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.62.0) - '@swc/core': 1.15.46(@swc/helpers@0.5.23) + '@swc/core': 1.15.47(@swc/helpers@0.5.23) load-tsconfig: 0.2.5 unplugin: 2.3.11 transitivePeerDependencies: @@ -26416,14 +26374,14 @@ snapshots: dependencies: punycode: 2.3.1 - url-loader@4.1.1(file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)))(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + url-loader@4.1.1(file-loader@6.2.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)))(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) optionalDependencies: - file-loader: 6.2.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + file-loader: 6.2.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) url@0.11.4: dependencies: @@ -26505,13 +26463,13 @@ snapshots: transitivePeerDependencies: - rollup - vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0): + vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -26526,22 +26484,22 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.1.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(@typescript/typescript6@6.0.2) - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0): + vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0): dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - postcss: 8.5.25 + postcss: 8.5.26 rollup: 4.62.0 tinyglobby: 0.2.17 optionalDependencies: @@ -26551,39 +26509,39 @@ snapshots: lightningcss: 1.33.0 sass: 1.102.0 terser: 5.49.0 - tsx: 4.23.1 + tsx: 4.23.9 yaml: 2.9.0 - vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0): + vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.1.5 + postcss: 8.5.26 + rolldown: 1.2.3 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 - esbuild: 0.28.1 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 sass: 1.102.0 terser: 5.49.0 - tsx: 4.23.1 + tsx: 4.23.9 yaml: 2.9.0 - vitefu@1.1.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)): optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) vitest-fetch-mock@0.4.5(vitest@4.1.10): dependencies: - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) - vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -26601,8 +26559,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -26623,10 +26581,10 @@ snapshots: - tsx - yaml - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -26643,7 +26601,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -26654,10 +26612,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -26674,7 +26632,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -26733,7 +26691,7 @@ snapshots: - bufferutil - utf-8-validate - webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: colorette: 2.0.20 memfs: 4.64.0(tslib@2.8.1) @@ -26742,11 +26700,11 @@ snapshots: range-parser: 1.3.0 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) transitivePeerDependencies: - tslib - webpack-dev-server@5.2.6(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tslib@2.8.1)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + webpack-dev-server@5.2.6(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tslib@2.8.1)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -26774,10 +26732,10 @@ snapshots: serve-index: 1.9.2(supports-color@8.1.1) sockjs: 0.3.24 spdy: 4.0.2(supports-color@8.1.1) - webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) ws: 8.21.1 optionalDependencies: - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) transitivePeerDependencies: - bufferutil - debug @@ -26803,7 +26761,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.106.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3): + webpack@5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.9 @@ -26826,7 +26784,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)(webpack@5.106.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + terser-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)(webpack@5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) watchpack: 2.5.2 webpack-sources: 3.5.1 transitivePeerDependencies: @@ -26843,7 +26801,7 @@ snapshots: - postcss - uglify-js - webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3): + webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 @@ -26861,7 +26819,7 @@ snapshots: graceful-fs: 4.2.11 loader-runner: 4.3.2 mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)) + minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 @@ -26881,14 +26839,14 @@ snapshots: - postcss - uglify-js - webpackbar@7.0.0(webpack@5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3)): + webpackbar@7.0.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: ansis: 3.17.0 consola: 3.4.2 pretty-time: 1.1.0 std-env: 3.10.0 optionalDependencies: - webpack: 5.108.4(@swc/core@1.15.46(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(uglify-js@3.19.3) + webpack: 5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) websocket-driver@0.7.5: dependencies: diff --git a/server/Dockerfile b/server/Dockerfile index df9d8ad7ba..c43d485144 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS builder +FROM ghcr.io/immich-app/base-server-dev:202608111116@sha256:f53e6bc2aa6e4c7b043e6ec1740448af4ba69e2090411daa7fa1df288bc0131a AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -56,7 +56,7 @@ FROM builder AS plugins ARG TARGETPLATFORM -COPY --from=ghcr.io/jdx/mise:2026.7.15@sha256:e62097049bfc980de5d9a25fbe16431e24c1e7713b3f77b18c04f81c25239953 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.8.3@sha256:92dbc3f2573926d8974e4641ad8449f16c323130b9f41c39aff19b7b2f500ef6 /usr/local/bin/mise /usr/local/bin/mise WORKDIR /app COPY ./mise.toml ./mise.toml @@ -81,7 +81,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ mise //:plugins -FROM ghcr.io/immich-app/base-server-prod:202607211135@sha256:ced131da7523544fe975cfd25abd67386e712e39496340b437cd95a08d0a18f3 +FROM ghcr.io/immich-app/base-server-prod:202608111116@sha256:15083a121ada9d243c3e717d84c2819a028f214c1f4bf3b39d0b63a299e994b3 WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 178f4440df..1570436ac2 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,8 +1,8 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS dev +FROM ghcr.io/immich-app/base-server-dev:202608111116@sha256:f53e6bc2aa6e4c7b043e6ec1740448af4ba69e2090411daa7fa1df288bc0131a AS dev -COPY --from=ghcr.io/jdx/mise:2026.7.15@sha256:e62097049bfc980de5d9a25fbe16431e24c1e7713b3f77b18c04f81c25239953 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.8.3@sha256:92dbc3f2573926d8974e4641ad8449f16c323130b9f41c39aff19b7b2f500ef6 /usr/local/bin/mise /usr/local/bin/mise RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 2cca2cab6d..949acaf139 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, @@ -76,6 +71,7 @@ export default typescriptEslint.config([ curly: 2, 'prettier/prettier': 0, 'object-shorthand': ['error', 'always'], + eqeqeq: 'error', 'no-restricted-imports': [ 'error', diff --git a/server/src/controllers/activity.controller.spec.ts b/server/src/controllers/activity.controller.spec.ts index 0b677b83fa..899c7257d6 100644 --- a/server/src/controllers/activity.controller.spec.ts +++ b/server/src/controllers/activity.controller.spec.ts @@ -19,11 +19,6 @@ describe(ActivityController.name, () => { }); describe('GET /activities', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/activities'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require an albumId', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/activities'); expect(status).toEqual(400); @@ -50,11 +45,6 @@ describe(ActivityController.name, () => { }); describe('POST /activities', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/activities'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require an albumId', async () => { const { status, body } = await request(ctx.getHttpServer()) .post('/activities') @@ -77,11 +67,6 @@ describe(ActivityController.name, () => { }); describe('DELETE /activities/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/activities/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`); expect(status).toBe(400); diff --git a/server/src/controllers/album.controller.spec.ts b/server/src/controllers/album.controller.spec.ts index 2ab7b08ceb..239a602100 100644 --- a/server/src/controllers/album.controller.spec.ts +++ b/server/src/controllers/album.controller.spec.ts @@ -19,11 +19,6 @@ describe(AlbumController.name, () => { }); describe('GET /albums', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/albums'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should reject an invalid shared param', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/albums?isShared=invalid'); expect(status).toEqual(400); @@ -40,60 +35,4 @@ describe(AlbumController.name, () => { expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }])); }); }); - - describe('GET /albums/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/albums/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /albums/statistics', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/albums/statistics'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('POST /albums', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/albums').send({ albumName: 'New album' }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('PUT /albums/:id/assets', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/albums/${factory.uuid()}/assets`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('PUT /albums/assets', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/albums/assets`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('PATCH /albums/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('DELETE /albums/:id/assets', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/albums/${factory.uuid()}/assets`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('PUT :id/users', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/albums/${factory.uuid()}/users`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); }); diff --git a/server/src/controllers/api-key.controller.spec.ts b/server/src/controllers/api-key.controller.spec.ts index 91a7c43a2d..08963362b6 100644 --- a/server/src/controllers/api-key.controller.spec.ts +++ b/server/src/controllers/api-key.controller.spec.ts @@ -19,33 +19,7 @@ describe(ApiKeyController.name, () => { ctx.reset(); }); - describe('POST /api-keys', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/api-keys').send({ name: 'API Key' }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /api-keys', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/api-keys'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /api-keys/me', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/api-keys/me`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('GET /api-keys/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`); expect(status).toBe(400); @@ -54,11 +28,6 @@ describe(ApiKeyController.name, () => { }); describe('PUT /api-keys/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/api-keys/${factory.uuid()}`).send({ name: 'new name' }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/api-keys/123`) @@ -76,11 +45,6 @@ describe(ApiKeyController.name, () => { }); describe('DELETE /api-keys/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/api-keys/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`); expect(status).toBe(400); diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index 668d88acbf..43d9a97780 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -44,11 +44,6 @@ describe(AssetMediaController.name, () => { }); describe('POST /assets', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post(`/assets`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should accept metadata', async () => { const mobileMetadata = { key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }; const { status } = await request(ctx.getHttpServer()) @@ -171,20 +166,9 @@ describe(AssetMediaController.name, () => { }); // TODO figure out how to deal with `sendFile` - describe.skip('GET /assets/:id/original', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/original`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); // TODO figure out how to deal with `sendFile` describe('GET /assets/:id/thumbnail', () => { - it.skip('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should redirect if size=original is requested', async () => { const { status } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail?size=original`); expect(status).toBe(302); diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 8ee6995b1f..92710d6ffc 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -20,11 +20,6 @@ describe(AssetController.name, () => { }); describe('PUT /assets', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/assets`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/assets`) @@ -59,13 +54,6 @@ describe(AssetController.name, () => { }); describe('DELETE /assets', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()) - .delete(`/assets`) - .send({ ids: [factory.uuid()] }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()) .delete(`/assets`) @@ -77,11 +65,6 @@ describe(AssetController.name, () => { }); describe('GET /assets/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`); expect(status).toBe(400); @@ -90,11 +73,6 @@ describe(AssetController.name, () => { }); describe('PUT /assets/copy', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/copy`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require target and source id', async () => { const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({}); expect(status).toBe(400); @@ -115,11 +93,6 @@ describe(AssetController.name, () => { }); describe('PUT /assets/metadata', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/assets/metadata`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid assetId', async () => { const { status, body } = await request(ctx.getHttpServer()) .put('/assets/metadata') @@ -151,11 +124,6 @@ describe(AssetController.name, () => { }); describe('DELETE /assets/metadata', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/assets/metadata`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid assetId', async () => { const { status, body } = await request(ctx.getHttpServer()) .delete('/assets/metadata') @@ -187,11 +155,6 @@ describe(AssetController.name, () => { }); describe('PUT /assets/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/123`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`); expect(status).toBe(400); @@ -267,26 +230,7 @@ describe(AssetController.name, () => { }); }); - describe('GET /assets/statistics', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/statistics`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /assets/:id/metadata', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('PUT /assets/:id/metadata', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] }); expect(status).toBe(400); @@ -353,11 +297,6 @@ describe(AssetController.name, () => { }); describe('GET /assets/:id/metadata/:key', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`); expect(status).toBe(400); @@ -366,11 +305,6 @@ describe(AssetController.name, () => { }); describe('PUT /assets/:id/edits', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/edits`).send({ edits: [] }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should accept valid edits and pass to service correctly', async () => { const edits = [ { @@ -456,11 +390,6 @@ describe(AssetController.name, () => { }); describe('DELETE /assets/:id/metadata/:key', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`); expect(status).toBe(400); diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts index 3bf1d59f8b..832547776d 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -199,28 +199,7 @@ describe(AuthController.name, () => { }); }); - describe('POST /auth/logout', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/auth/logout'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('POST /auth/change-password', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()) - .post('/auth/change-password') - .send({ password: 'password', newPassword: 'Password1234', invalidateSessions: false }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('POST /auth/pin-code', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '123456' }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should reject 5 digits', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' }); expect(status).toEqual(400); @@ -251,25 +230,4 @@ describe(AuthController.name, () => { ); }); }); - - describe('PUT /auth/pin-code', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put('/auth/pin-code').send({ pinCode: '123456', newPinCode: '654321' }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('DELETE /auth/pin-code', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete('/auth/pin-code').send({ pinCode: '123456' }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /auth/status', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/auth/status'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); }); diff --git a/server/src/controllers/database-backup.controller.spec.ts b/server/src/controllers/database-backup.controller.spec.ts index 43fa779e8d..57a7ecc310 100644 --- a/server/src/controllers/database-backup.controller.spec.ts +++ b/server/src/controllers/database-backup.controller.spec.ts @@ -25,13 +25,6 @@ describe(DatabaseBackupController.name, () => { ctx.reset(); }); - describe('GET /admin/database-backups', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/admin/database-backups').send(); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('POST /admin/database-backups/start-restore', () => { it('should not be an authenticated route', async () => { maintenanceService.startRestoreFlow.mockResolvedValue({ jwt: 'jwt' }); diff --git a/server/src/controllers/download.controller.spec.ts b/server/src/controllers/download.controller.spec.ts deleted file mode 100644 index f42afe8c50..0000000000 --- a/server/src/controllers/download.controller.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Readable } from 'node:stream'; -import { DownloadController } from 'src/controllers/download.controller'; -import { DownloadService } from 'src/services/download.service'; -import request from 'supertest'; -import { factory } from 'test/small.factory'; -import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; - -describe(DownloadController.name, () => { - let ctx: ControllerContext; - const service = mockBaseService(DownloadService); - - beforeAll(async () => { - ctx = await controllerSetup(DownloadController, [{ provide: DownloadService, useValue: service }]); - return () => ctx.close(); - }); - - beforeEach(() => { - service.resetAllMocks(); - ctx.reset(); - }); - - describe('POST /download/info', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()) - .post('/download/info') - .send({ assetIds: [factory.uuid()] }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('POST /download/archive', () => { - it('should be an authenticated route', async () => { - const stream = new Readable({ - read() { - // eslint-disable-next-line unicorn/no-this-outside-of-class - this.push('test'); - // eslint-disable-next-line unicorn/no-this-outside-of-class - this.push(null); - }, - }); - service.downloadArchive.mockResolvedValue({ stream }); - await request(ctx.getHttpServer()) - .post('/download/archive') - .send({ assetIds: [factory.uuid()] }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); -}); diff --git a/server/src/controllers/duplicate.controller.spec.ts b/server/src/controllers/duplicate.controller.spec.ts index 7bbafb4665..c8ca0e5793 100644 --- a/server/src/controllers/duplicate.controller.spec.ts +++ b/server/src/controllers/duplicate.controller.spec.ts @@ -18,26 +18,7 @@ describe(DuplicateController.name, () => { ctx.reset(); }); - describe('GET /duplicates', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/duplicates'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('DELETE /duplicates', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete('/duplicates'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('DELETE /duplicates/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/duplicates/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`); expect(status).toBe(400); diff --git a/server/src/controllers/maintenance.controller.spec.ts b/server/src/controllers/maintenance.controller.spec.ts index 630bb7c8b8..59b00b8aec 100644 --- a/server/src/controllers/maintenance.controller.spec.ts +++ b/server/src/controllers/maintenance.controller.spec.ts @@ -20,11 +20,6 @@ describe(MaintenanceController.name, () => { }); describe('POST /admin/maintenance', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/admin/maintenance').send(); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a backup file when action is restore', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/admin/maintenance').send({ action: MaintenanceAction.RestoreDatabase, diff --git a/server/src/controllers/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index a3839793eb..b717ee966c 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -20,11 +20,6 @@ describe(MemoryController.name, () => { }); describe('GET /memories', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/memories'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should not require any parameters', async () => { await request(ctx.getHttpServer()).get('/memories').query({}); expect(service.search).toHaveBeenCalled(); @@ -32,11 +27,6 @@ describe(MemoryController.name, () => { }); describe('POST /memories', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/memories'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should validate data when type is on this day', async () => { const { status, body } = await request(ctx.getHttpServer()) .post('/memories') @@ -69,19 +59,7 @@ describe(MemoryController.name, () => { }); }); - describe('GET /memories/statistics', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/memories/statistics'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('GET /memories/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/memories/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`); expect(status).toBe(400); @@ -90,11 +68,6 @@ describe(MemoryController.name, () => { }); describe('PUT /memories/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`); expect(status).toBe(400); @@ -114,19 +87,7 @@ describe(MemoryController.name, () => { }); }); - describe('DELETE /memories/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/memories/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('PUT /memories/:id/assets', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}/assets`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] }); expect(status).toBe(400); @@ -143,11 +104,6 @@ describe(MemoryController.name, () => { }); describe('DELETE /memories/:id/assets', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/memories/${factory.uuid()}/assets`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`); expect(status).toBe(400); diff --git a/server/src/controllers/notification-admin.controller.spec.ts b/server/src/controllers/notification-admin.controller.spec.ts index b93726eb32..e440f5bcf6 100644 --- a/server/src/controllers/notification-admin.controller.spec.ts +++ b/server/src/controllers/notification-admin.controller.spec.ts @@ -21,11 +21,6 @@ describe(NotificationAdminController.name, () => { }); describe('POST /admin/notifications', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/admin/notifications'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should accept a null readAt', async () => { await request(ctx.getHttpServer()) .post(`/admin/notifications`) diff --git a/server/src/controllers/notification.controller.spec.ts b/server/src/controllers/notification.controller.spec.ts index 1759e13404..f65537cbba 100644 --- a/server/src/controllers/notification.controller.spec.ts +++ b/server/src/controllers/notification.controller.spec.ts @@ -20,11 +20,6 @@ describe(NotificationController.name, () => { }); describe('GET /notifications', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/notifications'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should reject an invalid notification level`, async () => { const { status, body } = await request(ctx.getHttpServer()) .get(`/notifications`) @@ -40,11 +35,6 @@ describe(NotificationController.name, () => { }); describe('PUT /notifications', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put('/notifications'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - describe('ids', () => { it('should require a list', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true }); @@ -75,11 +65,6 @@ describe(NotificationController.name, () => { }); describe('GET /notifications/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/notifications/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`); expect(status).toBe(400); @@ -88,11 +73,6 @@ describe(NotificationController.name, () => { }); describe('PUT /notifications/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/notifications/${factory.uuid()}`).send({ readAt: factory.date() }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should accept a null readAt', async () => { const id = factory.uuid(); await request(ctx.getHttpServer()).put(`/notifications/${id}`).send({ readAt: null }); diff --git a/server/src/controllers/partner.controller.spec.ts b/server/src/controllers/partner.controller.spec.ts index d6541411b8..38cb717bea 100644 --- a/server/src/controllers/partner.controller.spec.ts +++ b/server/src/controllers/partner.controller.spec.ts @@ -3,7 +3,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { PartnerService } from 'src/services/partner.service'; import request from 'supertest'; import { errorDto } from 'test/medium/responses'; -import { factory } from 'test/small.factory'; import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(PartnerController.name, () => { @@ -24,11 +23,6 @@ describe(PartnerController.name, () => { }); describe('GET /partners', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/partners'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require a direction`, async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`); expect(status).toBe(400); @@ -54,11 +48,6 @@ describe(PartnerController.name, () => { }); describe('POST /partners', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/partners'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require sharedWithId to be a uuid`, async () => { const { status, body } = await request(ctx.getHttpServer()) .post(`/partners`) @@ -70,11 +59,6 @@ describe(PartnerController.name, () => { }); describe('PUT /partners/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/partners/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require id to be a uuid`, async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/partners/invalid`) @@ -86,11 +70,6 @@ describe(PartnerController.name, () => { }); describe('DELETE /partners/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/partners/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require id to be a uuid`, async () => { const { status, body } = await request(ctx.getHttpServer()) .delete(`/partners/invalid`) diff --git a/server/src/controllers/person.controller.spec.ts b/server/src/controllers/person.controller.spec.ts index 64fe4f3554..0d3d3eea4a 100644 --- a/server/src/controllers/person.controller.spec.ts +++ b/server/src/controllers/person.controller.spec.ts @@ -24,11 +24,6 @@ describe(PersonController.name, () => { }); describe('GET /people', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/people'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require closestPersonId to be a uuid`, async () => { const { status, body } = await request(ctx.getHttpServer()) .get(`/people`) @@ -48,19 +43,7 @@ describe(PersonController.name, () => { }); }); - describe('POST /people', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/people'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('DELETE /people', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete('/people'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require uuids in the body', async () => { const { status, body } = await request(ctx.getHttpServer()) .delete('/people') @@ -78,19 +61,7 @@ describe(PersonController.name, () => { }); }); - describe('GET /people/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('PUT /people/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`); expect(status).toBe(400); @@ -177,11 +148,6 @@ describe(PersonController.name, () => { }); describe('DELETE /people/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`); expect(status).toBe(400); @@ -194,18 +160,4 @@ describe(PersonController.name, () => { expect(service.delete).toHaveBeenCalled(); }); }); - - describe('POST /people/:id/merge', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post(`/people/${factory.uuid()}/merge`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /people/:id/statistics', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}/statistics`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); }); diff --git a/server/src/controllers/plugin.controller.spec.ts b/server/src/controllers/plugin.controller.spec.ts index 881a7dd953..bc42a4f75b 100644 --- a/server/src/controllers/plugin.controller.spec.ts +++ b/server/src/controllers/plugin.controller.spec.ts @@ -3,7 +3,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { PluginService } from 'src/services/plugin.service'; import request from 'supertest'; import { errorDto } from 'test/medium/responses'; -import { factory } from 'test/small.factory'; import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(PluginController.name, () => { @@ -24,11 +23,6 @@ describe(PluginController.name, () => { }); describe('GET /plugins', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/plugins'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require id to be a uuid`, async () => { const { status, body } = await request(ctx.getHttpServer()) .get(`/plugins`) @@ -40,11 +34,6 @@ describe(PluginController.name, () => { }); describe('GET /plugins/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/plugins/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require id to be a uuid`, async () => { const { status, body } = await request(ctx.getHttpServer()) .get(`/plugins/invalid`) diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index a1fed4c7ae..f1ac01ed41 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -19,11 +19,6 @@ describe(SearchController.name, () => { }); describe('POST /search/metadata', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/search/metadata'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should reject page as a string', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' }); expect(status).toBe(400); @@ -121,11 +116,6 @@ describe(SearchController.name, () => { }); describe('POST /search/random', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/search/random'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should reject if withStacked is not a boolean', async () => { const { status, body } = await request(ctx.getHttpServer()) .post('/search/random') @@ -151,26 +141,7 @@ describe(SearchController.name, () => { }); }); - describe('POST /search/smart', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/search/smart'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /search/explore', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/explore'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('POST /search/person', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/person'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a name', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({}); expect(status).toBe(400); @@ -181,11 +152,6 @@ describe(SearchController.name, () => { }); describe('GET /search/places', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/places'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a name', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({}); expect(status).toBe(400); @@ -195,19 +161,7 @@ describe(SearchController.name, () => { }); }); - describe('GET /search/cities', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/cities'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('GET /search/suggestions', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/suggestions'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a type', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({}); expect(status).toBe(400); diff --git a/server/src/controllers/server.controller.spec.ts b/server/src/controllers/server.controller.spec.ts deleted file mode 100644 index 6b00490d28..0000000000 --- a/server/src/controllers/server.controller.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ServerController } from 'src/controllers/server.controller'; -import { ServerService } from 'src/services/server.service'; -import { SystemMetadataService } from 'src/services/system-metadata.service'; -import { VersionService } from 'src/services/version.service'; -import request from 'supertest'; -import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; - -describe(ServerController.name, () => { - let ctx: ControllerContext; - const serverService = mockBaseService(ServerService); - const systemMetadataService = mockBaseService(SystemMetadataService); - const versionService = mockBaseService(VersionService); - - beforeAll(async () => { - ctx = await controllerSetup(ServerController, [ - { provide: ServerService, useValue: serverService }, - { provide: SystemMetadataService, useValue: systemMetadataService }, - { provide: VersionService, useValue: versionService }, - ]); - return () => ctx.close(); - }); - - beforeEach(() => { - serverService.resetAllMocks(); - versionService.resetAllMocks(); - ctx.reset(); - }); - - describe('GET /server/license', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/server/license'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); -}); diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts index d8b89d0029..731baa673f 100644 --- a/server/src/controllers/shared-link.controller.spec.ts +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -2,7 +2,8 @@ import { SharedLinkController } from 'src/controllers/shared-link.controller'; import { Permission, SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import request from 'supertest'; -import { factory } from 'test/small.factory'; +import { errorDto } from 'test/medium/responses'; +import { factory, newUuid } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(SharedLinkController.name, () => { @@ -19,18 +20,54 @@ describe(SharedLinkController.name, () => { ctx.reset(); }); + describe('GET /shared-links/me', () => { + it('should be a shared link route', async () => { + await request(ctx.getHttpServer()).get('/shared-links/me'); + expect(ctx.authenticate).toHaveBeenCalledWith( + expect.objectContaining({ metadata: expect.objectContaining({ sharedLinkRoute: true }) }), + ); + }); + }); + describe('POST /shared-links', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/shared-links'); - expect(ctx.authenticate).toHaveBeenCalled(); + it('should require a type and the correspondent asset/album id', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/shared-links') + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]), + ); }); it('should allow an null expiresAt', async () => { await request(ctx.getHttpServer()) .post('/shared-links') - .send({ expiresAt: null, type: SharedLinkType.Individual }); + .send({ expiresAt: null, type: SharedLinkType.Individual, assetIds: [newUuid()] }); expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); }); + + it('should not allow an albumId for share type Individual', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/shared-links') + .send({ type: SharedLinkType.Individual, assetIds: [newUuid()], albumId: newUuid() }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'albumId can only be used with type ALBUM' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should not allow assetIds for share type Album', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/shared-links') + .send({ type: SharedLinkType.Album, assetIds: [newUuid()], albumId: newUuid() }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'assetIds can only be used with type INDIVIDUAL' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); }); describe('DELETE /shared-links/:id/assets', () => { diff --git a/server/src/controllers/stack.controller.spec.ts b/server/src/controllers/stack.controller.spec.ts new file mode 100644 index 0000000000..bc9c87da14 --- /dev/null +++ b/server/src/controllers/stack.controller.spec.ts @@ -0,0 +1,46 @@ +import { StackController } from 'src/controllers/stack.controller'; +import { StackService } from 'src/services/stack.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(StackController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(StackService); + + beforeAll(async () => { + ctx = await controllerSetup(StackController, [{ provide: StackService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /stacks', () => { + it('should require at least two assets', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/stacks') + .send({ assetIds: [factory.uuid()] }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]), + ); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/stacks') + .send({ assetIds: ['invalid', 'invalid'] }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([ + { path: ['assetIds', 0], message: 'Invalid UUID' }, + { path: ['assetIds', 1], message: 'Invalid UUID' }, + ]), + ); + }); + }); +}); diff --git a/server/src/controllers/sync.controller.spec.ts b/server/src/controllers/sync.controller.spec.ts index cae7650d9a..1f2c0f137a 100644 --- a/server/src/controllers/sync.controller.spec.ts +++ b/server/src/controllers/sync.controller.spec.ts @@ -25,11 +25,6 @@ describe(SyncController.name, () => { }); describe('POST /sync/stream', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/sync/stream'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require sync request type enums', async () => { const { status, body } = await request(ctx.getHttpServer()) .post('/sync/stream') @@ -44,19 +39,7 @@ describe(SyncController.name, () => { }); }); - describe('GET /sync/ack', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/sync/ack'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('POST /sync/ack', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/sync/ack'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should not allow more than 1,000 entries', async () => { const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`); const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks }); @@ -69,11 +52,6 @@ describe(SyncController.name, () => { }); describe('DELETE /sync/ack', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete('/sync/ack'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require sync response type enums', async () => { const { status, body } = await request(ctx.getHttpServer()) .delete('/sync/ack') diff --git a/server/src/controllers/system-config.controller.spec.ts b/server/src/controllers/system-config.controller.spec.ts index 439b5ddec1..7d40f12583 100644 --- a/server/src/controllers/system-config.controller.spec.ts +++ b/server/src/controllers/system-config.controller.spec.ts @@ -40,26 +40,7 @@ describe(SystemConfigController.name, () => { ctx.reset(); }); - describe('GET /system-config', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/system-config'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /system-config/defaults', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/system-config/defaults'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('PUT /system-config', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put('/system-config'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - describe('nightlyTasks', () => { it('should validate nightly jobs start time', async () => { const config = validConfig(); diff --git a/server/src/controllers/tag.controller.spec.ts b/server/src/controllers/tag.controller.spec.ts index c2a2de95bd..a89ce14dda 100644 --- a/server/src/controllers/tag.controller.spec.ts +++ b/server/src/controllers/tag.controller.spec.ts @@ -2,7 +2,6 @@ import { TagController } from 'src/controllers/tag.controller'; import { TagService } from 'src/services/tag.service'; import request from 'supertest'; import { errorDto } from 'test/medium/responses'; -import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(TagController.name, () => { @@ -19,38 +18,14 @@ describe(TagController.name, () => { ctx.reset(); }); - describe('GET /tags', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/tags'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('POST /tags', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/tags'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should a null parentId', async () => { await request(ctx.getHttpServer()).post(`/tags`).send({ name: 'tag', parentId: null }); expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ parentId: null })); }); }); - describe('PUT /tags', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put('/tags'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('GET /tags/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/tags/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`); expect(status).toBe(400); @@ -58,10 +33,11 @@ describe(TagController.name, () => { }); }); - describe('PUT /tags/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); + describe('DELETE /tags/:id', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).delete(`/tags/123`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/timeline.controller.spec.ts b/server/src/controllers/timeline.controller.spec.ts index ff18c020c1..288945ee97 100644 --- a/server/src/controllers/timeline.controller.spec.ts +++ b/server/src/controllers/timeline.controller.spec.ts @@ -19,11 +19,6 @@ describe(TimelineController.name, () => { }); describe('GET /timeline/buckets', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/timeline/buckets'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should parse bbox query string into an object', async () => { const { status } = await request(ctx.getHttpServer()) .get('/timeline/buckets') @@ -58,11 +53,6 @@ describe(TimelineController.name, () => { }); describe('GET /timeline/bucket', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/timeline/bucket?timeBucket=1900-01-01'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - // TODO enable date string validation while still accepting 5 digit years it.fails('should fail if time bucket is invalid', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/timeline/bucket').query({ timeBucket: 'foo' }); diff --git a/server/src/controllers/user-admin.controller.spec.ts b/server/src/controllers/user-admin.controller.spec.ts index b5840a33e1..3fa423f9b3 100644 --- a/server/src/controllers/user-admin.controller.spec.ts +++ b/server/src/controllers/user-admin.controller.spec.ts @@ -24,26 +24,7 @@ describe(UserAdminController.name, () => { ctx.reset(); }); - describe('GET /admin/users', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/admin/users'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('PUT /admin/users/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('POST /admin/users', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/admin/users'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it('should allow a null pinCode', async () => { await request(ctx.getHttpServer()).post(`/admin/users`).send({ name: 'Test user', @@ -64,25 +45,22 @@ describe(UserAdminController.name, () => { expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ avatarColor: null })); }); - it(`should `, async () => { - const dto: UserAdminCreateDto = { - email: 'user@immich.app', - password: 'test', - name: 'Test User', - quotaSizeInBytes: 1.2, - }; - - const { status, body } = await request(ctx.getHttpServer()) - .post(`/admin/users`) - .set('Authorization', `Bearer token`) - .send(dto); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, - ]), - ); - }); + for (const [key, message] of [ + ['password', 'Invalid input: expected string, received null'], + ['email', 'Invalid input: expected email, received object'], + ['name', 'Invalid input: expected string, received null'], + ['shouldChangePassword', 'Invalid input: expected boolean, received null'], + ['notify', 'Invalid input: expected boolean, received null'], + ] as const) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/admin/users`) + .set('Authorization', `Bearer token`) + .send({ email: 'user@immich.app', password: 'test', name: 'Test User', [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); + }); + } it(`should not allow decimal quota`, async () => { const dto: UserAdminCreateDto = { @@ -105,19 +83,7 @@ describe(UserAdminController.name, () => { }); }); - describe('GET /admin/users/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('PUT /admin/users/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should not allow decimal quota`, async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/admin/users/${factory.uuid()}`) @@ -142,5 +108,21 @@ describe(UserAdminController.name, () => { await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ avatarColor: null }); expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ avatarColor: null })); }); + + for (const [key, message] of [ + ['password', 'Invalid input: expected string, received null'], + ['email', 'Invalid input: expected email, received object'], + ['name', 'Invalid input: expected string, received null'], + ['shouldChangePassword', 'Invalid input: expected boolean, received null'], + ] as const) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/admin/users/${factory.uuid()}`) + .set('Authorization', `Bearer token`) + .send({ [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); + }); + } }); }); diff --git a/server/src/controllers/user.controller.spec.ts b/server/src/controllers/user.controller.spec.ts index f512e2de39..d15eda31dc 100644 --- a/server/src/controllers/user.controller.spec.ts +++ b/server/src/controllers/user.controller.spec.ts @@ -3,7 +3,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { UserService } from 'src/services/user.service'; import request from 'supertest'; import { errorDto } from 'test/medium/responses'; -import { factory } from 'test/small.factory'; import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(UserController.name, () => { @@ -23,26 +22,7 @@ describe(UserController.name, () => { ctx.reset(); }); - describe('GET /users', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/users'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /users/me', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/users/me'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('PUT /users/me', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put('/users/me'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - for (const [key, message] of [ ['email', 'Invalid input: expected email, received object'], ['name', 'Invalid input: expected string, received null'], @@ -66,24 +46,31 @@ describe(UserController.name, () => { }); }); - describe('GET /users/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/users/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); + describe('PUT /users/me/preferences', () => { + it('should require an integer for download archive size', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/users/me/preferences`) + .set('Authorization', `Bearer token`) + .send({ download: { archiveSize: 1_234_567.89 } }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([ + { path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' }, + ]), + ); }); - }); - describe('PUT /users/me/license', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).put('/users/me/license'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('DELETE /users/me/license', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).delete('/users/me/license'); - expect(ctx.authenticate).toHaveBeenCalled(); + it('should require a boolean for download include embedded videos', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/users/me/preferences`) + .set('Authorization', `Bearer token`) + .send({ download: { includeEmbeddedVideos: 1_234_567.89 } }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([ + { path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' }, + ]), + ); }); }); }); diff --git a/server/src/controllers/workflow.controller.spec.ts b/server/src/controllers/workflow.controller.spec.ts index ad0dc4982d..0b91667671 100644 --- a/server/src/controllers/workflow.controller.spec.ts +++ b/server/src/controllers/workflow.controller.spec.ts @@ -4,7 +4,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { WorkflowService } from 'src/services/workflow.service'; import request from 'supertest'; import { errorDto } from 'test/medium/responses'; -import { factory } from 'test/small.factory'; import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(WorkflowController.name, () => { @@ -25,11 +24,6 @@ describe(WorkflowController.name, () => { }); describe('POST /workflows', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/workflows').send({}); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require a valid trigger`, async () => { const { status, body } = await request(ctx.getHttpServer()) .post(`/workflows`) @@ -65,11 +59,6 @@ describe(WorkflowController.name, () => { }); describe('GET /workflows', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/workflows'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require id to be a uuid`, async () => { const { status, body } = await request(ctx.getHttpServer()) .get(`/workflows`) @@ -81,11 +70,6 @@ describe(WorkflowController.name, () => { }); describe('GET /workflows/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/workflows/${factory.uuid()}`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require id to be a uuid`, async () => { const { status, body } = await request(ctx.getHttpServer()) .get(`/workflows/invalid`) @@ -96,11 +80,6 @@ describe(WorkflowController.name, () => { }); describe('PATCH /workflows/:id', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).patch(`/workflows/${factory.uuid()}`).send({}); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - it(`should require id to be a uuid`, async () => { const { status, body } = await request(ctx.getHttpServer()) .patch(`/workflows/invalid`) diff --git a/server/src/controllers/workflow.controller.ts b/server/src/controllers/workflow.controller.ts index b8d9fd2b93..b0d025763e 100644 --- a/server/src/controllers/workflow.controller.ts +++ b/server/src/controllers/workflow.controller.ts @@ -4,6 +4,8 @@ import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { WorkflowCreateDto, + WorkflowGetLogsDto, + WorkflowLogEntryDto, WorkflowResponseDto, WorkflowSearchDto, WorkflowShareResponseDto, @@ -113,4 +115,19 @@ export class WorkflowController { deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } + + @Get(':id/logs') + @Authenticated({ permission: Permission.WorkflowLogs }) + @Endpoint({ + summary: 'Retrieve workflow logs', + description: 'Retrieve logs of a workflows runs by ID', + history: HistoryBuilder.v3(), + }) + getWorkflowLogs( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Query() dto: WorkflowGetLogsDto, + ): Promise { + return this.service.getLogs(auth, id, dto); + } } diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 185ab95e62..07b753f820 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { StorageAsset } from 'src/database'; import { @@ -352,10 +351,6 @@ export class StorageCore { return join(StorageCore.getNestedFolder(folder, ownerId, filename), filename); } - static getTempPathInDir(dir: string): string { - return join(dir, `${randomUUID()}.tmp`); - } - private async getDevices() { try { return await this.storageRepository.readdir('/dev/dri'); diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index 6763f6ab74..d22875f286 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -3,7 +3,7 @@ import { SharedLink } from 'src/database'; import { HistoryBuilder } from 'src/decorators'; import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto'; import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto'; -import { SharedLinkTypeSchema } from 'src/enum'; +import { SharedLinkType, SharedLinkTypeSchema } from 'src/enum'; import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; @@ -31,6 +31,29 @@ const SharedLinkCreateSchema = z allowDownload: z.boolean().default(true).optional().describe('Allow downloads'), showMetadata: z.boolean().default(true).optional().describe('Show metadata'), }) + .superRefine(({ type, albumId, assetIds }, ctx) => { + switch (type) { + case SharedLinkType.Album: { + if (!albumId) { + ctx.addIssue(`albumId is required for type ${SharedLinkType.Album}`); + } + if (assetIds) { + ctx.addIssue(`assetIds can only be used with type ${SharedLinkType.Individual}`); + } + return; + } + case SharedLinkType.Individual: { + if (!assetIds || assetIds.length === 0) { + ctx.addIssue(`assetIds are required for type ${SharedLinkType.Individual}`); + } + + if (albumId) { + ctx.addIssue(`albumId can only be used with type ${SharedLinkType.Album}`); + } + return; + } + } + }) .meta({ id: 'SharedLinkCreateDto' }); const SharedLinkEditSchema = z diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts index b4269ea0c9..990cae79a1 100644 --- a/server/src/dtos/workflow.dto.ts +++ b/server/src/dtos/workflow.dto.ts @@ -1,6 +1,7 @@ import type { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk'; import { createZodDto } from 'nestjs-zod'; -import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum'; +import { WorkflowResultSchema, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum'; +import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; const WorkflowTriggerResponseSchema = z @@ -17,6 +18,7 @@ const WorkflowSearchSchema = z name: z.string().optional().describe('Workflow name'), description: z.string().optional().describe('Workflow description'), enabled: z.boolean().optional().describe('Workflow enabled'), + logging: z.boolean().optional().describe('Workflow logs run results'), }) .meta({ id: 'WorkflowSearchDto' }); @@ -42,6 +44,7 @@ const WorkflowCreateSchema = z name: z.string().nullable().optional().describe('Workflow name'), description: z.string().nullable().optional().describe('Workflow description'), enabled: z.boolean().optional().describe('Workflow enabled'), + logging: z.boolean().optional().describe('Workflow logs run results'), steps: z.array(WorkflowStepSchema).optional(), }) .meta({ id: 'WorkflowCreateDto' }); @@ -52,6 +55,7 @@ const WorkflowUpdateSchema = z name: z.string().nullable().optional().describe('Workflow name'), description: z.string().nullable().optional().describe('Workflow description'), enabled: z.boolean().optional().describe('Workflow enabled'), + logging: z.boolean().optional().describe('Workflow logs run results'), steps: z.array(WorkflowStepSchema).optional(), }) .meta({ id: 'WorkflowUpdateDto' }); @@ -65,6 +69,7 @@ const WorkflowResponseSchema = z createdAt: z.string().describe('Creation date'), updatedAt: z.string().describe('Update date'), enabled: z.boolean().describe('Workflow enabled'), + logging: z.boolean().describe('Workflow logs run results'), steps: z.array(WorkflowStepSchema).describe('Workflow steps'), }) .meta({ id: 'WorkflowResponseDto' }); @@ -78,12 +83,36 @@ const WorkflowShareResponseSchema = z }) .meta({ id: 'WorkflowShareResponseDto' }); +const WorkflowLogEntrySchema = z + .object({ + id: z.uuidv4().describe('Workflow log entry ID'), + at: isoDatetimeToDate.describe('Workflow run date/time'), + result: WorkflowResultSchema.describe('Workflow run result'), + triggerDataId: z.uuid().optional().describe('Workflow trigger data ID'), + lastStep: z + .object({ + method: z.string().describe('Method of the step'), + index: z.int().positive().describe('Index of the step in the workflow'), + }) + .optional() + .describe('Last step ran, if the workflow ended early'), + }) + .meta({ id: 'WorkflowLogEntryDto' }); + +const WorkflowGetLogsSchema = z.object({ + result: WorkflowResultSchema.optional().describe('Filter by run result'), + before: isoDatetimeToDate.optional().describe('Filter by runs before a date/time'), + limit: z.coerce.number().int().positive().default(50).describe('Maximum number of logs'), +}); + export class WorkflowTriggerResponseDto extends createZodDto(WorkflowTriggerResponseSchema) {} export class WorkflowSearchDto extends createZodDto(WorkflowSearchSchema) {} export class WorkflowCreateDto extends createZodDto(WorkflowCreateSchema) {} export class WorkflowUpdateDto extends createZodDto(WorkflowUpdateSchema) {} export class WorkflowResponseDto extends createZodDto(WorkflowResponseSchema) {} export class WorkflowShareResponseDto extends createZodDto(WorkflowShareResponseSchema) {} +export class WorkflowLogEntryDto extends createZodDto(WorkflowLogEntrySchema) {} +export class WorkflowGetLogsDto extends createZodDto(WorkflowGetLogsSchema) {} type Workflow = { id: string; @@ -93,6 +122,7 @@ type Workflow = { name: string | null; description: string | null; enabled: boolean; + logging: boolean; }; type WorkflowStep = { @@ -107,6 +137,7 @@ export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): Wor id: workflow.id, enabled: workflow.enabled, trigger: workflow.trigger, + logging: workflow.logging, name: workflow.name, description: workflow.description, createdAt: workflow.createdAt.toISOString(), diff --git a/server/src/enum.ts b/server/src/enum.ts index 0d29244e09..e88a9a667c 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -298,6 +298,7 @@ export enum Permission { WorkflowRead = 'workflow.read', WorkflowUpdate = 'workflow.update', WorkflowDelete = 'workflow.delete', + WorkflowLogs = 'workflow.logs', AdminUserCreate = 'adminUser.create', AdminUserRead = 'adminUser.read', @@ -1210,14 +1211,6 @@ export enum ApiTag { Workflows = 'Workflows', } -export enum PluginContext { - Asset = 'asset', - Album = 'album', - Person = 'person', -} - -export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' }); - export const WorkflowTriggerSchema = z .enum(WorkflowTrigger) .describe('Plugin trigger type') @@ -1235,6 +1228,17 @@ export enum CalendarHeatmapType { Taken = 'Taken', } +export enum WorkflowResult { + Completed = 'completed', + Halted = 'halted', + Error = 'error', +} + +export const WorkflowResultSchema = z + .enum(WorkflowResult) + .describe('Workflow run result') + .meta({ id: 'WorkflowResult' }); + export enum SearchOrderField { FileCreatedAt = 'fileCreatedAt', LocalDateTime = 'localDateTime', diff --git a/server/src/maintenance/maintenance-auth.guard.ts b/server/src/maintenance/maintenance-auth.guard.ts index 08aaad516b..e2c472ff71 100644 --- a/server/src/maintenance/maintenance-auth.guard.ts +++ b/server/src/maintenance/maintenance-auth.guard.ts @@ -1,11 +1,4 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - SetMetadata, - applyDecorators, - createParamDecorator, -} from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable, SetMetadata, applyDecorators } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; @@ -22,14 +15,6 @@ export interface MaintenanceAuthRequest extends Request { auth?: MaintenanceAuthDto; } -export interface MaintenanceAuthenticatedRequest extends Request { - auth: MaintenanceAuthDto; -} - -export const MaintenanceAuth = createParamDecorator((data, context: ExecutionContext): MaintenanceAuthDto => { - return context.switchToHttp().getRequest().auth; -}); - @Injectable() export class MaintenanceAuthGuard implements CanActivate { constructor( diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql index f44ba54066..293d053d81 100644 --- a/server/src/queries/workflow.repository.sql +++ b/server/src/queries/workflow.repository.sql @@ -9,6 +9,7 @@ select "workflow"."enabled", "workflow"."createdAt", "workflow"."updatedAt", + "workflow"."logging", ( select coalesce(json_agg(agg), '[]') @@ -43,6 +44,7 @@ select "workflow"."enabled", "workflow"."createdAt", "workflow"."updatedAt", + "workflow"."logging", ( select coalesce(json_agg(agg), '[]') @@ -73,6 +75,7 @@ select "workflow"."id", "workflow"."name", "workflow"."trigger", + "workflow"."logging", ( select coalesce(json_agg(agg), '[]') @@ -100,6 +103,39 @@ where "id" = $2 and "enabled" = $3 +-- WorkflowRepository.getLogs +select + "workflow_log"."id", + "workflow_log"."createdAt", + "workflow_log"."result", + "workflow_log"."workflowId", + "workflow_log"."workflowStepId", + "workflow_log"."triggerDataId", + ( + select + to_json(obj) + from + ( + select + "plugin_method"."pluginId", + "plugin_method"."name" as "methodName", + "workflow_step"."order" + from + "workflow_step" + inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId" + where + "workflow_step"."id" = "workflow_log"."workflowStepId" + ) as obj + ) as "step" +from + "workflow_log" +where + "workflow_log"."workflowId" = $1 +order by + "workflow_log"."createdAt" desc +limit + $2 + -- WorkflowRepository.delete delete from "workflow" where diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index ca00245c27..add3f0a24d 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -838,7 +838,7 @@ export class AssetRepository { ) .$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude'])) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) - .$if(options.visibility == undefined, withDefaultVisibility) + .$if(options.visibility === undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) .$if(!!options.bbox, (qb) => { const bbox = options.bbox!; @@ -899,7 +899,7 @@ export class AssetRepository { .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) .orderBy( - options.orderBy == AssetOrderBy.CreatedAt + options.orderBy === AssetOrderBy.CreatedAt ? sql`"createdAt"` : sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order, diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 1b5592d356..165752783a 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -483,35 +483,6 @@ export class DatabaseRepository { await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection); } - async revertLastMigration(): Promise { - this.logger.debug('Reverting last migration'); - - const migrator = this.createMigrator(); - const { error, results } = await migrator.migrateDown(); - - for (const result of results ?? []) { - if (result.status === 'Success') { - this.logger.log(`Reverted migration "${result.migrationName}"`); - } else if (result.status === 'Error') { - this.logger.warn(`Failed to revert migration "${result.migrationName}"`); - } - } - - if (error) { - this.logger.error(`Failed to revert migrations: ${error}`); - throw error; - } - - const reverted = results?.find((result) => result.direction === 'Down' && result.status === 'Success'); - if (!reverted) { - this.logger.debug('No migrations to revert'); - return undefined; - } - - this.logger.debug('Finished reverting migration'); - return reverted.migrationName; - } - private createMigrator(): Migrator { return new Migrator({ db: this.db, diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 416f823952..7fedc4eb3a 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -43,7 +43,7 @@ type EventMap = { // asset events AssetCreate: [{ asset: Pick; file?: UploadFile }]; - AssetTag: [{ assetId: string }]; + AssetTag: [{ assetId: string; userId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; AssetShow: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index c9825f31c1..a05ddbc866 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -73,7 +73,6 @@ export interface Face { } export type FacialRecognitionResponse = { [ModelTask.FACIAL_RECOGNITION]: Face[] } & VisualResponse; -export type DetectedFaces = { faces: Face[] } & VisualResponse; export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest | OcrRequest; export type TextEncodingOptions = ModelOptions & { language?: string }; diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 0db03a18c7..8ea6ed50fb 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFace } from 'src/database'; @@ -60,8 +60,6 @@ export interface GetAllFacesOptions { export type UnassignFacesOptions = DeleteFacesOptions; -export type SelectFaceOptions = (keyof Selectable)[]; - const withPerson = (eb: ExpressionBuilder) => { return jsonObjectFrom( eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'), diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 70ba09ef2c..4bde10f165 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -158,8 +158,6 @@ export type SmartSearchOptions = SearchDateOptions & SearchTagOptions & SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' }; -export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions; - export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number }; export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index 285ab4ee80..5cfded148d 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -8,16 +8,6 @@ import { ReleaseChannel } from 'src/dtos/system-config.dto'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -export interface GitHubRelease { - id: number; - url: string; - tag_name: string; - name: string; - created_at: string; - published_at: string; - body: string; -} - export interface VersionResponse { version: string; published_at: string; diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index f1cb541b39..58a5e721c0 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -9,8 +9,6 @@ import { DB } from 'src/schema'; import { SessionTable } from 'src/schema/tables/session.table'; import { asUuid } from 'src/utils/database'; -export type SessionSearchOptions = { updatedBefore: Date }; - @Injectable() export class SessionRepository { constructor(@InjectKysely() private db: Kysely) {} diff --git a/server/src/repositories/telemetry.repository.ts b/server/src/repositories/telemetry.repository.ts index 9a486005b5..146eaa30d7 100644 --- a/server/src/repositories/telemetry.repository.ts +++ b/server/src/repositories/telemetry.repository.ts @@ -149,7 +149,7 @@ export class TelemetryRepository { const unit = 'ms'; for (const [propName, descriptor] of Object.entries(descriptors)) { - const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor'; + const isMethod = typeof descriptor.value === 'function' && propName !== 'constructor'; if (!isMethod) { continue; } diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 11a6e532aa..afe14fb499 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -321,7 +321,7 @@ export class UserRepository { updatedAt: new Date(), }) .where('user.deletedAt', 'is', null) - .$if(id != undefined, (eb) => eb.where('user.id', '=', asUuid(id!))); + .$if(id !== undefined, (eb) => eb.where('user.id', '=', asUuid(id!))); await query.execute(); } diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 9c946fe583..8344896199 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -4,10 +4,12 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { WorkflowSearchDto } from 'src/dtos/workflow.dto'; +import { WorkflowGetLogsDto, WorkflowSearchDto } from 'src/dtos/workflow.dto'; import { DB } from 'src/schema'; +import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; import { WorkflowTable } from 'src/schema/tables/workflow.table'; +import { withTags } from 'src/utils/database'; export type WorkflowStepUpsert = Omit, 'workflowId' | 'order'>; @@ -26,6 +28,7 @@ export class WorkflowRepository { 'workflow.enabled', 'workflow.createdAt', 'workflow.updatedAt', + 'workflow.logging', ]) .select((eb) => [ jsonArrayFrom( @@ -65,7 +68,7 @@ export class WorkflowRepository { getForWorkflowRun(id: string) { return this.db .selectFrom('workflow') - .select(['workflow.id', 'workflow.name', 'workflow.trigger']) + .select(['workflow.id', 'workflow.name', 'workflow.trigger', 'workflow.logging']) .select((eb) => [ jsonArrayFrom( eb @@ -98,6 +101,9 @@ export class WorkflowRepository { update(id: string, dto: Updateable, steps?: WorkflowStepUpsert[]) { return this.db.transaction().execute(async (tx) => { + if (dto.logging === false) { + await tx.deleteFrom('workflow_log').where('workflowId', '=', id).execute(); + } if (Object.values(dto).some((prop) => prop !== undefined)) { await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow(); } @@ -105,6 +111,39 @@ export class WorkflowRepository { }); } + @GenerateSql({ params: [DummyValue.UUID, { result: undefined }] }) + getLogs(id: string, dto: WorkflowGetLogsDto) { + return this.db + .selectFrom('workflow_log') + .select([ + 'workflow_log.id', + 'workflow_log.createdAt', + 'workflow_log.result', + 'workflow_log.workflowId', + 'workflow_log.workflowStepId', + 'workflow_log.triggerDataId', + ]) + .where('workflow_log.workflowId', '=', id) + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom('workflow_step') + .whereRef('workflow_step.id', '=', 'workflow_log.workflowStepId') + .innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId') + .select(['plugin_method.pluginId', 'plugin_method.name as methodName', 'workflow_step.order']), + ).as('step'), + ]) + .$if(dto.result !== undefined, (qb) => qb.where('workflow_log.result', '=', dto.result!)) + .$if(dto.before !== undefined, (qb) => qb.where('workflow_log.createdAt', '<', dto.before!)) + .orderBy('workflow_log.createdAt', 'desc') + .limit(dto.limit) + .execute(); + } + + log(dto: Insertable) { + return this.db.insertInto('workflow_log').values(dto).execute(); + } + async updateStep(id: string, dto: Updateable) { await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute(); } @@ -143,6 +182,7 @@ export class WorkflowRepository { .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .select((eb) => [ ...columns.workflowAssetV1, + withTags, jsonObjectFrom( eb .selectFrom('asset_exif') diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 230f22478d..2ee61d82dd 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -133,6 +133,20 @@ export const album_asset_delete_audit = registerFunction({ END`, }); +export const album_user_delete = registerFunction({ + name: 'album_user_delete', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + DELETE FROM "album" + WHERE "album"."id" = OLD."albumId" + AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner'); + + RETURN NULL; + END`, +}); + export const album_user_delete_audit = registerFunction({ name: 'album_user_delete_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 4e55ff4cf6..45423f7c40 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -7,6 +7,7 @@ import { } from 'src/schema/enums'; import { album_user_after_insert, + album_user_delete, album_user_delete_audit, asset_delete_audit, asset_face_audit, @@ -86,6 +87,7 @@ import { VideoStreamSessionTable, VideoStreamVariantTable, } from 'src/schema/tables/video-stream.table'; +import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; import { WorkflowTable } from 'src/schema/tables/workflow.table'; @@ -174,6 +176,7 @@ export class ImmichDatabase { asset_metadata_audit, asset_face_audit, asset_ocr_delete_audit, + album_user_delete, ]; enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum]; @@ -277,4 +280,5 @@ export interface DB { workflow: WorkflowTable; workflow_step: WorkflowStepTable; + workflow_log: WorkflowLogTable; } diff --git a/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts b/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts new file mode 100644 index 0000000000..082e43ff23 --- /dev/null +++ b/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts @@ -0,0 +1,31 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`DELETE FROM "album" WHERE NOT EXISTS (SELECT * FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner');`.execute(db); + await sql`CREATE OR REPLACE FUNCTION album_user_delete() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + DELETE FROM "album" + WHERE "album"."id" = OLD."albumId" + AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner'); + + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_user_delete" + AFTER DELETE ON "album_user" + REFERENCING OLD TABLE AS "old" + FOR EACH ROW + EXECUTE FUNCTION album_user_delete();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_user_delete', '{"type":"function","name":"album_user_delete","sql":"CREATE OR REPLACE FUNCTION album_user_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n DELETE FROM \\"album\\"\\n WHERE \\"album\\".\\"id\\" = OLD.\\"albumId\\"\\n AND NOT EXISTS (SELECT \\"albumId\\" FROM \\"album_user\\" WHERE \\"album_user\\".\\"albumId\\" = \\"album\\".\\"id\\" AND \\"album_user\\".\\"role\\" = ''owner'');\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_delete', '{"type":"trigger","name":"album_user_delete","sql":"CREATE OR REPLACE TRIGGER \\"album_user_delete\\"\\n AFTER DELETE ON \\"album_user\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION album_user_delete();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "album_user_delete" ON "album_user";`.execute(db); + await sql`DROP FUNCTION album_user_delete;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_user_delete';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_delete';`.execute(db); +} diff --git a/server/src/schema/migrations/1786741078327-AddWorkflowLogsTable.ts b/server/src/schema/migrations/1786741078327-AddWorkflowLogsTable.ts new file mode 100644 index 0000000000..13fa5f5f97 --- /dev/null +++ b/server/src/schema/migrations/1786741078327-AddWorkflowLogsTable.ts @@ -0,0 +1,24 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "workflow" ADD "logging" boolean NOT NULL DEFAULT false;`.execute(db); + await sql`CREATE TABLE "workflow_log" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "workflowId" uuid NOT NULL, + "result" character varying NOT NULL, + "workflowStepId" uuid, + "triggerDataId" uuid, + "runId" uuid NOT NULL, + CONSTRAINT "workflow_log_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_log_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "workflow_step" ("id") ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT "workflow_log_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_log_workflowId_idx" ON "workflow_log" ("workflowId");`.execute(db); + await sql`CREATE INDEX "workflow_log_workflowStepId_idx" ON "workflow_log" ("workflowStepId");`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "workflow" DROP COLUMN "logging";`.execute(db); + await sql`DROP TABLE "workflow_log";`.execute(db); +} diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 677d6ca2f2..c015c1354d 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -13,7 +13,7 @@ import { import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AlbumUserRole } from 'src/enum'; import { album_user_role_enum } from 'src/schema/enums'; -import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; +import { album_user_after_insert, album_user_delete, album_user_delete_audit } from 'src/schema/functions'; import { AlbumTable } from 'src/schema/tables/album.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -38,6 +38,7 @@ import { UserTable } from 'src/schema/tables/user.table'; referencingOldTableAs: 'old', when: 'pg_trigger_depth() <= 1', }) +@AfterDeleteTrigger({ scope: 'row', function: album_user_delete, referencingOldTableAs: 'old' }) export class AlbumUserTable { @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', diff --git a/server/src/schema/tables/workflow-log.table.ts b/server/src/schema/tables/workflow-log.table.ts new file mode 100644 index 0000000000..e720cb2a37 --- /dev/null +++ b/server/src/schema/tables/workflow-log.table.ts @@ -0,0 +1,36 @@ +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + PrimaryGeneratedColumn, + Table, + Timestamp, +} from '@immich/sql-tools'; +import { WorkflowResult } from 'src/enum'; +import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; + +@Table('workflow_log') +export class WorkflowLogTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @CreateDateColumn() + createdAt!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', index: true }) + workflowId!: string; + + @Column() + result!: WorkflowResult; + + @ForeignKeyColumn(() => WorkflowStepTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true }) + workflowStepId!: string | null; + + @Column({ type: 'uuid', nullable: true }) + triggerDataId!: string | null; + + @Column({ type: 'uuid' }) + runId!: string; +} diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts index 944fffd9d5..c0c9dc94e4 100644 --- a/server/src/schema/tables/workflow.table.ts +++ b/server/src/schema/tables/workflow.table.ts @@ -41,4 +41,7 @@ export class WorkflowTable { @Column({ type: 'boolean', default: true }) enabled!: Generated; + + @Column({ type: 'boolean', default: false }) + logging!: Generated; } diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 071c5d5aec..a94049bbd2 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -19,6 +19,7 @@ import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.reposi import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; import { asDateTimeString } from 'src/utils/date'; +import { findOrFail } from 'src/utils/misc'; import { getPreferences } from 'src/utils/preferences'; @Injectable() @@ -351,11 +352,7 @@ export class AlbumService extends BaseService { await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } - private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) { - const album = await this.albumRepository.getById(id, options, authUserId); - if (!album) { - throw new BadRequestException('Album not found'); - } - return album; + private findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) { + return findOrFail(() => this.albumRepository.getById(id, options, authUserId), 'Album'); } } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 1edccf9483..15385df0e0 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; -import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { AssetFile } from 'src/database'; import { OnJob } from 'src/decorators'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; @@ -46,6 +45,7 @@ import { } from 'src/utils/asset.util'; import { updateLockedColumns } from 'src/utils/database'; import { extractTimeZone } from 'src/utils/date'; +import { batched, findOrFail } from 'src/utils/misc'; import { transformOcrBoundingBox } from 'src/utils/transform'; @Injectable() @@ -278,31 +278,12 @@ export class AssetService extends BaseService { .minus(Duration.fromObject({ days: trashedDays })) .toJSDate(); - let chunk: Array<{ id: string; isOffline: boolean }> = []; - const queueChunk = async () => { - if (chunk.length === 0) { - return; - } - + for await (const assets of batched(this.assetJobRepository.streamForDeletedJob(trashedBefore))) { await this.jobRepository.queueAll( - chunk.map(({ id, isOffline }) => ({ - name: JobName.AssetDelete, - data: { id, deleteOnDisk: !isOffline }, - })), + assets.map(({ id, isOffline }) => ({ name: JobName.AssetDelete, data: { id, deleteOnDisk: !isOffline } })), ); - chunk = []; - }; - - const assets = this.assetJobRepository.streamForDeletedJob(trashedBefore); - for await (const asset of assets) { - chunk.push(asset); - if (chunk.length >= JOBS_ASSET_PAGINATION_SIZE) { - await queueChunk(); - } } - await queueChunk(); - return JobStatus.Success; } @@ -496,12 +477,8 @@ export class AssetService extends BaseService { await this.jobRepository.queueAll(jobs); } - private async findOrFail(id: string) { - const asset = await this.assetRepository.getById(id); - if (!asset) { - throw new BadRequestException('Asset not found'); - } - return asset; + private findOrFail(id: string) { + return findOrFail(() => this.assetRepository.getById(id), 'Asset'); } private async updateExif(dto: { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 59e20276af..5603819212 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -549,7 +549,7 @@ export class AuthService extends BaseService { const now = DateTime.now(); const updatedAt = DateTime.fromJSDate(session.updatedAt); const diff = now.diff(updatedAt, ['hours']); - if (diff.hours > 1 || appVersion != session.appVersion) { + if (diff.hours > 1 || appVersion !== session.appVersion) { await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date(), diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 2195c66a22..6ed8ce925a 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -117,7 +117,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ ViewRepository, WebsocketRepository, WorkflowRepository, -]; +] as const; @Injectable() export class BaseService { @@ -191,7 +191,7 @@ export class BaseService { ); } - static create(Service: ClassConstructor, ctx: BaseService) { + static create>(Service: T, ctx: BaseService) { const service = new Service( LoggingRepository.create(), ctx.accessRepository, @@ -242,6 +242,7 @@ export class BaseService { ctx.trashRepository, ctx.userRepository, ctx.versionRepository, + ctx.videoStreamRepository, ctx.viewRepository, ctx.websocketRepository, ctx.workflowRepository, @@ -249,7 +250,7 @@ export class BaseService { service.logger.setContext(BaseService.name); - return service as T; + return service as InstanceType; } get worker() { diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 8cb04e29ba..08c7630f91 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; @@ -8,9 +7,9 @@ import { DuplicateResolveDto, DuplicateResolveGroupDto, DuplicateResponseDto } f import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; -import { JobItem, JobOf } from 'src/types'; +import { JobOf } from 'src/types'; import { suggestDuplicateKeepAssetIds } from 'src/utils/duplicate'; -import { isDuplicateDetectionEnabled } from 'src/utils/misc'; +import { batched, isDuplicateDetectionEnabled } from 'src/utils/misc'; type ResolveRequest = { assetUpdate: { @@ -307,22 +306,12 @@ export class DuplicateService extends BaseService { return JobStatus.Skipped; } - let jobs: JobItem[] = []; - const queueAll = async () => { - await this.jobRepository.queueAll(jobs); - jobs = []; - }; - - const assets = this.assetJobRepository.streamForSearchDuplicates(force); - for await (const asset of assets) { - jobs.push({ name: JobName.AssetDetectDuplicates, data: { id: asset.id } }); - if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { - await queueAll(); - } + for await (const assets of batched(this.assetJobRepository.streamForSearchDuplicates(force))) { + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.AssetDetectDuplicates, data: { id: asset.id } })), + ); } - await queueAll(); - return JobStatus.Success; } diff --git a/server/src/services/integrity.service.ts b/server/src/services/integrity.service.ts index a7a194b25f..6c7a505812 100644 --- a/server/src/services/integrity.service.ts +++ b/server/src/services/integrity.service.ts @@ -35,7 +35,7 @@ import { IIntegrityUntrackedFilesJob, } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; -import { handlePromiseError } from 'src/utils/misc'; +import { batched, handlePromiseError } from 'src/utils/misc'; /** * Untracked Files: @@ -201,7 +201,7 @@ export class IntegrityService extends BaseService { const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReport.UntrackedFile); let total = 0; - for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) { + for await (const batchReports of batched(reports, JOBS_LIBRARY_PAGINATION_SIZE)) { await this.jobRepository.queue({ name: JobName.IntegrityUntrackedFilesRefresh, data: { @@ -338,7 +338,7 @@ export class IntegrityService extends BaseService { const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReport.MissingFile); let total = 0; - for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) { + for await (const batchReports of batched(reports, JOBS_LIBRARY_PAGINATION_SIZE)) { await this.jobRepository.queue({ name: JobName.IntegrityMissingFilesRefresh, data: { @@ -365,7 +365,7 @@ export class IntegrityService extends BaseService { const assetPaths = this.integrityRepository.streamAssetPathsForMissingFiles(); let total = 0; - for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) { + for await (const batchPaths of batched(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) { await this.jobRepository.queue({ name: JobName.IntegrityMissingFiles, data: { @@ -450,7 +450,7 @@ export class IntegrityService extends BaseService { const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReport.ChecksumFail); let total = 0; - for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) { + for await (const batchReports of batched(reports, JOBS_LIBRARY_PAGINATION_SIZE)) { await this.jobRepository.queue({ name: JobName.IntegrityChecksumFilesRefresh, data: { @@ -656,7 +656,7 @@ export class IntegrityService extends BaseService { for (const property of properties) { const reports = this.integrityRepository.streamIntegrityReportsByProperty(property, type); - for await (const batch of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) { + for await (const batch of batched(reports, JOBS_LIBRARY_PAGINATION_SIZE)) { await this.jobRepository.queue({ name: JobName.IntegrityDeleteReports, data: { @@ -705,19 +705,3 @@ export class IntegrityService extends BaseService { return JobStatus.Success; } } - -async function* chunk(generator: AsyncIterableIterator, n: number) { - let chunk: T[] = []; - for await (const item of generator) { - chunk.push(item); - - if (chunk.length === n) { - yield chunk; - chunk = []; - } - } - - if (chunk.length > 0) { - yield chunk; - } -} diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index f13a440f84..fa405b9d22 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -34,7 +34,7 @@ import { AssetTable } from 'src/schema/tables/asset.table'; import { BaseService } from 'src/services/base.service'; import { JobOf } from 'src/types'; import { mimeTypes } from 'src/utils/mime-types'; -import { handlePromiseError } from 'src/utils/misc'; +import { batched, findOrFail, handlePromiseError } from 'src/utils/misc'; @Injectable() export class LibraryService extends BaseService { @@ -110,7 +110,7 @@ export class LibraryService extends BaseService { const handler = async (event: string, path: string) => { if (matcher(path)) { - this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`); + this.logger.debug(`File ${event} event received for ${path} in library ${library.id}`); await this.jobRepository.queue({ name: JobName.LibrarySyncFiles, data: { libraryId: library.id, paths: [path] }, @@ -121,7 +121,7 @@ export class LibraryService extends BaseService { }; const deletionHandler = async (path: string) => { - this.logger.debug(`File unlink event received for ${path} in library ${library.id}}`); + this.logger.debug(`File unlink event received for ${path} in library ${library.id}`); await this.jobRepository.queue({ name: JobName.LibraryRemoveAsset, data: { libraryId: library.id, paths: [path] }, @@ -375,35 +375,20 @@ export class LibraryService extends BaseService { await this.assetRepository.updateByLibraryId(libraryId, { deletedAt: new Date() }); - let isAssetsFound = false; - let chunk: string[] = []; - - const queueChunk = async () => { - if (chunk.length === 0) { - return; - } - - isAssetsFound = true; - this.logger.debug(`Queueing deletion of ${chunk.length} asset(s) in library ${libraryId}`); - await this.jobRepository.queueAll( - chunk.map((id) => ({ name: JobName.AssetDelete, data: { id, deleteOnDisk: false } })), - ); - chunk = []; - }; - this.logger.debug(`Will delete all assets in library ${libraryId}`); - const assets = this.libraryRepository.streamAssetIds(libraryId); - for await (const asset of assets) { - chunk.push(asset.id); - - if (chunk.length >= JOBS_LIBRARY_PAGINATION_SIZE) { - await queueChunk(); - } + let hasAssets = false; + for await (const assets of batched( + this.libraryRepository.streamAssetIds(libraryId), + JOBS_LIBRARY_PAGINATION_SIZE, + )) { + this.logger.debug(`Queueing deletion of ${assets.length} asset(s) in library ${libraryId}`); + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.AssetDelete, data: { id: asset.id, deleteOnDisk: false } })), + ); + hasAssets = true; } - await queueChunk(); - - if (!isAssetsFound) { + if (!hasAssets) { this.logger.log(`Deleting library ${libraryId}`); await this.libraryRepository.delete(libraryId); } @@ -459,10 +444,6 @@ export class LibraryService extends BaseService { await this.jobRepository.queue({ name: JobName.LibrarySyncAssetsQueueAll, data: { id } }); } - async queueScanAll() { - await this.jobRepository.queue({ name: JobName.LibraryScanQueueAll, data: {} }); - } - @OnJob({ name: JobName.LibraryScanQueueAll, queue: QueueName.Library }) async handleQueueScanAll(): Promise { this.logger.log(`Initiating scan of all external libraries...`); @@ -746,15 +727,12 @@ export class LibraryService extends BaseService { return JobStatus.Success; } - let chunk: string[] = []; + this.logger.log(`Scanning library ${library.id} for assets missing from disk...`); + let count = 0; - - const queueChunk = async () => { - if (chunk.length === 0) { - return; - } - - count += chunk.length; + const existingAssets = this.libraryRepository.streamAssetIds(library.id); + for await (const assets of batched(existingAssets, JOBS_LIBRARY_PAGINATION_SIZE)) { + count += assets.length; await this.jobRepository.queue({ name: JobName.LibrarySyncAssets, @@ -762,42 +740,25 @@ export class LibraryService extends BaseService { libraryId: library.id, importPaths: library.importPaths, exclusionPatterns: library.exclusionPatterns, - assetIds: chunk.map((id) => id), + assetIds: assets.map(({ id }) => id), progressCounter: count, totalAssets: assetCount, }, }); - chunk = []; const completePercentage = ((100 * count) / assetCount).toFixed(1); this.logger.log( `Queued check of ${count} of ${assetCount} (${completePercentage} %) existing asset(s) so far in library ${library.id}`, ); - }; - - this.logger.log(`Scanning library ${library.id} for assets missing from disk...`); - const existingAssets = this.libraryRepository.streamAssetIds(library.id); - - for await (const asset of existingAssets) { - chunk.push(asset.id); - if (chunk.length === JOBS_LIBRARY_PAGINATION_SIZE) { - await queueChunk(); - } } - await queueChunk(); - this.logger.log(`Finished queuing ${count} asset check(s) for library ${library.id}`); return JobStatus.Success; } - private async findOrFail(id: string) { - const library = await this.libraryRepository.get(id); - if (!library) { - throw new BadRequestException('Library not found'); - } - return library; + private findOrFail(id: string) { + return findOrFail(() => this.libraryRepository.get(id), 'Library'); } } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 290bf947a1..d64e0088eb 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -207,8 +207,7 @@ describe(MediaService.name, () => { await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([]); - + expect(mocks.job.queueAll).toHaveBeenCalledTimes(1); expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); @@ -237,7 +236,10 @@ describe(MediaService.name, () => { await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([]); + expect(mocks.job.queueAll).toHaveBeenCalledTimes(1); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } }, + ]); expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 956636dd51..19b1688e88 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; -import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; +import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; import { AssetFile } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; @@ -43,7 +43,7 @@ import { getAssetFile, getDimensions } from 'src/utils/asset.util'; import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; -import { clamp } from 'src/utils/misc'; +import { batched, clamp } from 'src/utils/misc'; import { getOutputDimensions } from 'src/utils/transform'; interface UpsertFileOptions { @@ -69,52 +69,42 @@ export class MediaService extends BaseService { @OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration }) async handleQueueGenerateThumbnails({ force }: JobOf): Promise { const config = await this.getConfig({ withCache: true }); - let jobs: JobItem[] = []; - - const queueAll = async () => { - await this.jobRepository.queueAll(jobs); - jobs = []; - }; const isFullsizeEnabled = config.image.fullsize.enabled; - for await (const asset of this.assetJobRepository.streamForThumbnailJob({ - force, - fullsizeEnabled: isFullsizeEnabled, - })) { - if (force || !asset.isEdited) { - jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }); - } - - if (asset.isEdited) { - jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } }); - } - - if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { - await queueAll(); - } - } - - await queueAll(); - - const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }); - - for await (const person of people) { - if (!person.faceAssetId) { - const face = await this.personRepository.getRandomFace(person.id); - if (!face) { - continue; + for await (const assets of batched( + this.assetJobRepository.streamForThumbnailJob({ force, fullsizeEnabled: isFullsizeEnabled }), + )) { + const jobs: JobItem[] = []; + for (const asset of assets) { + if (force || !asset.isEdited) { + jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }); } - await this.personRepository.update({ id: person.id, faceAssetId: face.id }); + if (asset.isEdited) { + jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } }); + } } - jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } }); - if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { - await queueAll(); - } + await this.jobRepository.queueAll(jobs); } - await queueAll(); + for await (const people of batched(this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }))) { + const jobs: JobItem[] = []; + for (const person of people) { + if (!person.faceAssetId) { + const face = await this.personRepository.getRandomFace(person.id); + if (!face) { + continue; + } + + await this.personRepository.update({ id: person.id, faceAssetId: face.id }); + } + + jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } }); + } + + await this.jobRepository.queueAll(jobs); + } return JobStatus.Success; } @@ -127,30 +117,18 @@ export class MediaService extends BaseService { await this.storageCore.removeEmptyDirs(StorageFolder.EncodedVideo); } - let jobs: JobItem[] = []; - const assets = this.assetJobRepository.streamForMigrationJob(); - for await (const asset of assets) { - jobs.push({ name: JobName.AssetFileMigration, data: { id: asset.id } }); - if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { - await this.jobRepository.queueAll(jobs); - jobs = []; - } + for await (const assets of batched(this.assetJobRepository.streamForMigrationJob())) { + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.AssetFileMigration, data: { id: asset.id } })), + ); } - await this.jobRepository.queueAll(jobs); - jobs = []; - - for await (const person of this.personRepository.getAll()) { - jobs.push({ name: JobName.PersonFileMigration, data: { id: person.id } }); - - if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { - await this.jobRepository.queueAll(jobs); - jobs = []; - } + for await (const people of batched(this.personRepository.getAll())) { + await this.jobRepository.queueAll( + people.map((person) => ({ name: JobName.PersonFileMigration, data: { id: person.id } })), + ); } - await this.jobRepository.queueAll(jobs); - return JobStatus.Success; } @@ -551,18 +529,12 @@ export class MediaService extends BaseService { async handleQueueVideoConversion(job: JobOf): Promise { const { force } = job; - let queue: { name: JobName.AssetEncodeVideo; data: { id: string } }[] = []; - for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) { - queue.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } }); - - if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { - await this.jobRepository.queueAll(queue); - queue = []; - } + for await (const assets of batched(this.assetJobRepository.streamForVideoConversion(force))) { + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.AssetEncodeVideo, data: { id: asset.id } })), + ); } - await this.jobRepository.queueAll(queue); - return JobStatus.Success; } diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 41510056ff..1175a11af4 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { Memory } from 'src/database'; import { OnJob } from 'src/decorators'; @@ -8,6 +8,7 @@ import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, m import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { findOrFail } from 'src/utils/misc'; const DAYS = 3; @@ -162,11 +163,7 @@ export class MemoryService extends BaseService { return results; } - private async findOrFail(id: string) { - const memory = await this.memoryRepository.get(id); - if (!memory) { - throw new BadRequestException('Memory not found'); - } - return memory; + private findOrFail(id: string) { + return findOrFail(() => this.memoryRepository.get(id), 'Memory'); } } diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 37dd92e27d..a95d1f1497 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -6,7 +6,7 @@ import { DateTime, Duration } from 'luxon'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { join, parse } from 'node:path'; -import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; + import { StorageCore } from 'src/cores/storage.core'; import { Asset, AssetFile } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; @@ -30,12 +30,12 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { BaseService } from 'src/services/base.service'; -import { JobItem, JobOf } from 'src/types'; +import { JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { isAssetChecksumConstraint } from 'src/utils/database'; import { mergeTimeZone } from 'src/utils/date'; import { mimeTypes } from 'src/utils/mime-types'; -import { isFaceImportEnabled } from 'src/utils/misc'; +import { batched, isFaceImportEnabled } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; import { Tasks } from 'src/utils/tasks'; @@ -109,7 +109,7 @@ const validateRange = (value: number | undefined, min: number, max: number): Non const val = validate(value); // check if the value is within the range - if (val == null || val < min || val > max) { + if (val === null || val < min || val > max) { return null; } @@ -218,17 +218,12 @@ export class MetadataService extends BaseService { async handleQueueMetadataExtraction(job: JobOf): Promise { const { force } = job; - let queue: { name: JobName.AssetExtractMetadata; data: { id: string } }[] = []; - for await (const asset of this.assetJobRepository.streamForMetadataExtraction(force)) { - queue.push({ name: JobName.AssetExtractMetadata, data: { id: asset.id } }); - - if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { - await this.jobRepository.queueAll(queue); - queue = []; - } + for await (const assets of batched(this.assetJobRepository.streamForMetadataExtraction(force))) { + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.AssetExtractMetadata, data: { id: asset.id } })), + ); } - await this.jobRepository.queueAll(queue); return JobStatus.Success; } @@ -377,8 +372,8 @@ export class MetadataService extends BaseService { fileModifiedAt: stats.mtime, // Keep unedited assets in sync with the file on disk, but don't overwrite edited dimensions. - width: !asset.isEdited || asset.width == null ? assetWidth : undefined, - height: !asset.isEdited || asset.height == null ? assetHeight : undefined, + width: !asset.isEdited || asset.width === null ? assetWidth : undefined, + height: !asset.isEdited || asset.height === null ? assetHeight : undefined, }), async () => { await this.assetRepository.upsertExif({ @@ -417,22 +412,12 @@ export class MetadataService extends BaseService { @OnJob({ name: JobName.SidecarQueueAll, queue: QueueName.Sidecar }) async handleQueueSidecar({ force }: JobOf): Promise { - let jobs: JobItem[] = []; - const queueAll = async () => { - await this.jobRepository.queueAll(jobs); - jobs = []; - }; - - const assets = this.assetJobRepository.streamForSidecar(force); - for await (const asset of assets) { - jobs.push({ name: JobName.SidecarCheck, data: { id: asset.id } }); - if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { - await queueAll(); - } + for await (const assets of batched(this.assetJobRepository.streamForSidecar(force))) { + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.SidecarCheck, data: { id: asset.id } })), + ); } - await queueAll(); - return JobStatus.Success; } @@ -1003,7 +988,7 @@ export class MetadataService extends BaseService { // timezone let timeZone = exifTags.zone ?? null; - if (timeZone == null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) { + if (timeZone === null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) { // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly // https://github.com/photostructure/exiftool-vendored.js/issues/203 timeZone = 'UTC+0'; diff --git a/server/src/services/ocr.service.ts b/server/src/services/ocr.service.ts index d92d399dba..8e32a44376 100644 --- a/server/src/services/ocr.service.ts +++ b/server/src/services/ocr.service.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; + import { OnJob } from 'src/decorators'; import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum'; import { OCR } from 'src/repositories/machine-learning.repository'; import { BaseService } from 'src/services/base.service'; -import { JobItem, JobOf } from 'src/types'; +import { JobOf } from 'src/types'; import { tokenizeForSearch } from 'src/utils/database'; -import { isOcrEnabled } from 'src/utils/misc'; +import { batched, isOcrEnabled } from 'src/utils/misc'; @Injectable() export class OcrService extends BaseService { @@ -21,19 +21,10 @@ export class OcrService extends BaseService { await this.ocrRepository.deleteAll(); } - let jobs: JobItem[] = []; - const assets = this.assetJobRepository.streamForOcrJob(force); - - for await (const asset of assets) { - jobs.push({ name: JobName.Ocr, data: { id: asset.id } }); - - if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { - await this.jobRepository.queueAll(jobs); - jobs = []; - } + for await (const assets of batched(this.assetJobRepository.streamForOcrJob(force))) { + await this.jobRepository.queueAll(assets.map((asset) => ({ name: JobName.Ocr, data: { id: asset.id } }))); } - await this.jobRepository.queueAll(jobs); return JobStatus.Success; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 8e0cd2ff01..7c08c36e17 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Insertable, Updateable } from 'kysely'; -import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { Person } from 'src/database'; import { Chunked, OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; @@ -43,7 +42,7 @@ import { JobItem, JobOf } from 'src/types'; import { getDimensions } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; -import { isFacialRecognitionEnabled } from 'src/utils/misc'; +import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc'; import { Point, transformPoints } from 'src/utils/transform'; @Injectable() @@ -277,19 +276,12 @@ export class PersonService extends BaseService { await this.personRepository.vacuum({ reindexVectors: true }); } - let jobs: JobItem[] = []; - const assets = this.assetJobRepository.streamForDetectFacesJob(force); - for await (const asset of assets) { - jobs.push({ name: JobName.AssetDetectFaces, data: { id: asset.id } }); - - if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { - await this.jobRepository.queueAll(jobs); - jobs = []; - } + for await (const assets of batched(this.assetJobRepository.streamForDetectFacesJob(force))) { + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.AssetDetectFaces, data: { id: asset.id } })), + ); } - await this.jobRepository.queueAll(jobs); - if (force === undefined) { await this.jobRepository.queue({ name: JobName.PersonCleanup }); } @@ -435,22 +427,16 @@ export class PersonService extends BaseService { await this.databaseRepository.prewarm(VectorIndex.Face); const lastRun = new Date().toISOString(); - const facePagination = this.personRepository.getAllFaces( + + const faces = this.personRepository.getAllFaces( force ? undefined : { personId: null, sourceType: SourceType.MachineLearning }, ); - - let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = []; - for await (const face of facePagination) { - jobs.push({ name: JobName.FacialRecognition, data: { id: face.id, deferred: false } }); - - if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { - await this.jobRepository.queueAll(jobs); - jobs = []; - } + for await (const batch of batched(faces)) { + await this.jobRepository.queueAll( + batch.map((face) => ({ name: JobName.FacialRecognition, data: { id: face.id, deferred: false } })), + ); } - await this.jobRepository.queueAll(jobs); - await this.systemMetadataRepository.set(SystemMetadataKey.FacialRecognitionState, { lastRun }); return JobStatus.Success; @@ -614,12 +600,8 @@ export class PersonService extends BaseService { return results; } - private async findOrFail(id: string) { - const person = await this.personRepository.getById(id); - if (!person) { - throw new BadRequestException('Person not found'); - } - return person; + private findOrFail(id: string) { + return findOrFail(() => this.personRepository.getById(id), 'Person'); } // TODO return a asset face response diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 41b1d5403c..31ca32f552 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -13,7 +13,7 @@ import { } from 'src/dtos/shared-link.dto'; import { Permission, SharedLinkType } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { getExternalDomain, OpenGraphTags } from 'src/utils/misc'; +import { findOrFail, getExternalDomain, OpenGraphTags } from 'src/utils/misc'; @Injectable() export class SharedLinkService extends BaseService { @@ -143,12 +143,8 @@ export class SharedLinkService extends BaseService { } // TODO: replace `userId` with permissions and access control checks - private async findOrFail(userId: string, id: string) { - const sharedLink = await this.sharedLinkRepository.get(userId, id); - if (!sharedLink) { - throw new BadRequestException('Shared link not found'); - } - return sharedLink; + private findOrFail(userId: string, id: string) { + return findOrFail(() => this.sharedLinkRepository.get(userId, id), 'Shared link'); } async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 93b8944099..19a1774492 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; -import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; + import { OnEvent, OnJob } from 'src/decorators'; import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; -import { JobItem, JobOf } from 'src/types'; -import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; +import { JobOf } from 'src/types'; +import { batched, getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() export class SmartInfoService extends BaseService { @@ -77,18 +77,10 @@ export class SmartInfoService extends BaseService { await this.databaseRepository.setDimensionSize(dimSize); } - let queue: JobItem[] = []; - const assets = this.assetJobRepository.streamForEncodeClip(force); - for await (const asset of assets) { - queue.push({ name: JobName.SmartSearch, data: { id: asset.id } }); - if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { - await this.jobRepository.queueAll(queue); - queue = []; - } + for await (const assets of batched(this.assetJobRepository.streamForEncodeClip(force))) { + await this.jobRepository.queueAll(assets.map((asset) => ({ name: JobName.SmartSearch, data: { id: asset.id } }))); } - await this.jobRepository.queueAll(queue); - return JobStatus.Success; } diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index d47d634f4f..933c7f39c5 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -85,8 +85,9 @@ describe(StackService.name, () => { it('should fail if stack could not be found', async () => { mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(void 0); - await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(Error); + await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); @@ -124,8 +125,9 @@ describe(StackService.name, () => { it('should fail if stack could not be found', async () => { mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(void 0); - await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(Error); + await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); expect(mocks.stack.update).not.toHaveBeenCalled(); diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts index 2dc9f1547e..9dc47a97bf 100644 --- a/server/src/services/stack.service.ts +++ b/server/src/services/stack.service.ts @@ -4,6 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; import { Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { findOrFail } from 'src/utils/misc'; import { UUIDAssetIDParamDto } from 'src/validation'; @Injectable() @@ -77,12 +78,7 @@ export class StackService extends BaseService { await this.eventRepository.emit('StackUpdate', { stackId, userId: auth.user.id }); } - private async findOrFail(id: string) { - const stack = await this.stackRepository.getById(id); - if (!stack) { - throw new Error('Asset stack not found'); - } - - return stack; + private findOrFail(id: string) { + return findOrFail(() => this.stackRepository.getById(id), 'Asset stack'); } } diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index e4f2dcacf7..de731f46c6 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -402,8 +402,8 @@ export class StorageTemplateService extends BaseService { const substitutions: Record = { filename, ext: extension, - filetype: asset.type == AssetType.Image ? 'IMG' : 'VID', - filetypefull: asset.type == AssetType.Image ? 'IMAGE' : 'VIDEO', + filetype: asset.type === AssetType.Image ? 'IMG' : 'VID', + filetypefull: asset.type === AssetType.Image ? 'IMAGE' : 'VIDEO', assetId: asset.id, assetIdShort: asset.id.slice(-12), //just throw into the root if it doesn't belong to an album diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 1c7195e4c3..08a9b00104 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -17,6 +17,7 @@ import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; import { updateLockedColumns } from 'src/utils/database'; +import { findOrFail } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; @Injectable() @@ -92,7 +93,7 @@ export class TagService extends BaseService { const results = await this.tagRepository.upsertAssetIds(items); for (const assetId of new Set(results.map((item) => item.assetId))) { await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return { count: results.length }; @@ -113,7 +114,7 @@ export class TagService extends BaseService { } await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return results; @@ -146,12 +147,8 @@ export class TagService extends BaseService { return JobStatus.Success; } - private async findOrFail(id: string) { - const tag = await this.tagRepository.get(id); - if (!tag) { - throw new BadRequestException('Tag not found'); - } - return tag; + private findOrFail(id: string) { + return findOrFail(() => this.tagRepository.get(id), 'Tag'); } private async updateTags(assetId: string) { diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index f1d368111e..368e16beb5 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TrashResponseDto } from 'src/dtos/trash.dto'; import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { batched } from 'src/utils/misc'; @Injectable() export class TrashService extends BaseService { @@ -47,39 +47,16 @@ export class TrashService extends BaseService { @OnJob({ name: JobName.AssetEmptyTrash, queue: QueueName.BackgroundTask }) async handleEmptyTrash() { - const assets = this.trashRepository.getDeletedIds(); - let count = 0; - const batch: string[] = []; - for await (const { id } of assets) { - batch.push(id); - - if (batch.length === JOBS_ASSET_PAGINATION_SIZE) { - await this.handleBatch(batch); - count += batch.length; - batch.length = 0; - } + for await (const assets of batched(this.trashRepository.getDeletedIds())) { + await this.jobRepository.queueAll( + assets.map(({ id }) => ({ name: JobName.AssetDelete, data: { id, deleteOnDisk: true } })), + ); + count += assets.length; } - await this.handleBatch(batch); - count += batch.length; - batch.length = 0; - this.logger.log(`Queued ${count} asset(s) for deletion from the trash`); return JobStatus.Success; } - - private async handleBatch(ids: string[]) { - this.logger.debug(`Queueing ${ids.length} asset(s) for deletion from the trash`); - await this.jobRepository.queueAll( - ids.map((assetId) => ({ - name: JobName.AssetDelete, - data: { - id: assetId, - deleteOnDisk: true, - }, - })), - ); - } } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 15ea8c0598..f80a810616 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -17,6 +17,7 @@ import { JobName, UserMetadataKey, UserStatus } from 'src/enum'; import { UserFindOptions } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; import { getCalendarHeatmap } from 'src/services/shared/user-methods'; +import { findOrFail } from 'src/utils/misc'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() @@ -158,11 +159,7 @@ export class UserAdminService extends BaseService { return mapPreferences(newPreferences); } - private async findOrFail(id: string, options: UserFindOptions) { - const user = await this.userRepository.get(id, options); - if (!user) { - throw new BadRequestException('User not found'); - } - return user; + private findOrFail(id: string, options: UserFindOptions) { + return findOrFail(() => this.userRepository.get(id, options), 'User'); } } diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index c7a5bc4e02..1630fd9dd9 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -20,6 +20,7 @@ import { getCalendarHeatmap } from 'src/services/shared/user-methods'; import { JobOf, UserMetadataItem } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; +import { findOrFail } from 'src/utils/misc'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; import { generateProfileImage } from 'src/utils/profile-image'; @@ -302,11 +303,7 @@ export class UserService extends BaseService { return DateTime.now().minus({ days: delayUntilDeletion }) > DateTime.fromJSDate(user.deletedAt); } - private async findOrFail(id: string, options: UserFindOptions) { - const user = await this.userRepository.get(id, options); - if (!user) { - throw new BadRequestException('User not found'); - } - return user; + private findOrFail(id: string, options: UserFindOptions) { + return findOrFail(() => this.userRepository.get(id, options), 'User'); } } diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index 318f36a4b6..193fab16de 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -13,6 +13,7 @@ import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { TagBulkAssetsDto } from 'src/dtos/tag.dto'; import { BootstrapEventPriority, DatabaseLock, @@ -21,12 +22,14 @@ import { JobName, JobStatus, QueueName, + WorkflowResult, WorkflowType, } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { AlbumService } from 'src/services/album.service'; import { AssetService } from 'src/services/asset.service'; import { BaseService } from 'src/services/base.service'; +import { TagService } from 'src/services/tag.service'; import { JobOf } from 'src/types'; const dummy = () => { @@ -36,7 +39,7 @@ const dummy = () => { }; type ExecuteOptions = { - read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData }>; + read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData; entityId?: string }>; write: (auth: AuthDto, changes: WorkflowChanges) => Promise; }; @@ -69,6 +72,7 @@ export class WorkflowExecutionService extends BaseService { this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); const albumService = BaseService.create(AlbumService, this); + const tagService = BaseService.create(TagService, this); const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args)); const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args)); @@ -106,6 +110,9 @@ export class WorkflowExecutionService extends BaseService { throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest'); }); + const bulkTagAssets = this.wrap<[dto: TagBulkAssetsDto]>((authDto, ctx, args) => + tagService.bulkTagAssets(authDto, ...args), + ); const functions = { searchAlbums, @@ -113,6 +120,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum, addAssetsToAlbums, httpRequest, + bulkTagAssets, }; const stubs: typeof functions = { @@ -121,6 +129,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum: dummy, addAssetsToAlbums: dummy, httpRequest: dummy, + bulkTagAssets: dummy, }; const plugins = await this.pluginRepository.getForLoad(); @@ -309,6 +318,11 @@ export class WorkflowExecutionService extends BaseService { return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction }); } + @OnEvent({ name: 'AssetTag' }) + onAssetTagged({ assetId, userId }: ArgOf<'AssetTag'>) { + return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetTagged }); + } + private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) { const items = await this.workflowRepository.search({ userId, trigger }); await this.jobRepository.queueAll( @@ -332,6 +346,7 @@ export class WorkflowExecutionService extends BaseService { return { data: { asset } as any, authUserId: asset.ownerId, + entityId: asset.id, }; }, write: async (auth, changes) => { @@ -399,11 +414,13 @@ export class WorkflowExecutionService extends BaseService { return; } - try { - const { read, write } = handler; - const readResult = await read(type); - let data = readResult.data; - for (const step of workflow.steps) { + const { read, write } = handler; + const readResult = await read(type); + let data = readResult.data; + const runId = crypto.randomUUID(); + + for (const step of workflow.steps) { + try { const payload: WorkflowEventPayload = { trigger: workflow.trigger, type, @@ -454,14 +471,45 @@ export class WorkflowExecutionService extends BaseService { const shouldContinue = result?.workflow?.continue ?? true; if (!shouldContinue) { - break; - } - } + if (workflow.logging) { + await this.workflowRepository.log({ + workflowId, + result: WorkflowResult.Halted, + workflowStepId: step.id, + triggerDataId: readResult.entityId, + runId, + }); + } - this.logger.debug(`Workflow ${workflowId} executed successfully`); - } catch (error) { - this.logger.error(`Error executing workflow ${workflowId}:`, error); - return JobStatus.Failed; + this.logger.debug(`Workflow ${workflowId} run ${runId} stopped on step ${step.id}`); + return; + } + } catch (error) { + this.logger.error(`Error executing workflow ${workflowId} run ${runId}:`, error); + + if (workflow.logging) { + await this.workflowRepository.log({ + workflowId, + result: WorkflowResult.Error, + workflowStepId: step.id, + triggerDataId: readResult.entityId, + runId, + }); + } + + return JobStatus.Failed; + } } + + if (workflow.logging) { + await this.workflowRepository.log({ + workflowId, + result: WorkflowResult.Completed, + triggerDataId: readResult.entityId, + runId, + }); + } + + this.logger.debug(`Workflow ${workflowId} run ${runId} executed successfully`); } } diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts index 850b4ac086..e68e6aa94e 100644 --- a/server/src/services/workflow.service.ts +++ b/server/src/services/workflow.service.ts @@ -5,6 +5,8 @@ import { mapWorkflow, mapWorkflowShare, WorkflowCreateDto, + WorkflowGetLogsDto, + WorkflowLogEntryDto, WorkflowResponseDto, WorkflowSearchDto, WorkflowShareResponseDto, @@ -14,6 +16,7 @@ import { import { Permission } from 'src/enum'; import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository'; import { BaseService } from 'src/services/base.service'; +import { findOrFail } from 'src/utils/misc'; import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow'; @Injectable() @@ -82,6 +85,23 @@ export class WorkflowService extends BaseService { await this.workflowRepository.delete(id); } + async getLogs(auth: AuthDto, id: string, dto: WorkflowGetLogsDto): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowLogs, ids: [id] }); + const logs = await this.workflowRepository.getLogs(id, dto); + return logs.map((entry) => ({ + id: entry.id, + at: entry.createdAt, + result: entry.result, + triggerDataId: entry.triggerDataId ?? undefined, + lastStep: entry.step + ? { + index: entry.step.order, + method: `${entry.step.pluginId}#${entry.step.methodName}`, + } + : undefined, + })); + } + private async resolveAndValidateSteps(steps: T[], trigger: WorkflowTrigger) { const methods = await this.pluginRepository.getForValidation(); const results: Array = []; @@ -104,11 +124,7 @@ export class WorkflowService extends BaseService { return results; } - private async findOrFail(id: string) { - const workflow = await this.workflowRepository.get(id); - if (!workflow) { - throw new BadRequestException('Workflow not found'); - } - return workflow; + private findOrFail(id: string) { + return findOrFail(() => this.workflowRepository.get(id), 'Workflow'); } } diff --git a/server/src/types.ts b/server/src/types.ts index dca3119c76..d31841ffd3 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,4 +1,3 @@ -import { WorkflowTrigger } from '@immich/plugin-sdk'; import { ShallowDehydrateObject } from 'kysely'; import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; @@ -30,8 +29,8 @@ import { SystemMetadataKey, TranscodeTarget, UserMetadataKey, - WorkflowType, } from 'src/enum'; +import { Mocked } from 'vitest'; export type DeepPartial = T extends Date ? T @@ -78,14 +77,6 @@ export interface DecodeToBufferOptions extends DecodeImageOptions { export type GenerateThumbnailOptions = Pick & DecodeToBufferOptions; export type GenerateThumbhashOptions = DecodeImageOptions; -export interface GenerateThumbnailsOptions { - colorspace: string; - preview?: ImageOptions; - processInvalidImages: boolean; - thumbhash?: boolean; - thumbnail?: ImageOptions; -} - export interface VideoStreamInfo { index: number; height: number; @@ -142,10 +133,6 @@ export interface ImageDimensions { height: number; } -export interface InputDimensions extends ImageDimensions { - inputPath: string; -} - export interface VideoInfo { format: VideoFormat; videoStreams: VideoStreamInfo[]; @@ -188,11 +175,6 @@ export interface BitrateDistribution { unit: string; } -export interface ImageBuffer { - data: Buffer; - info: RawImageInfo; -} - export interface VideoCodecSWConfig { getCommand( target: TranscodeTarget, @@ -259,18 +241,10 @@ export interface ILibraryBulkIdsJob { totalAssets: number; } -export interface IBulkEntityJob { - ids: string[]; -} - export interface IDeleteFilesJob extends IBaseJob { files: Array; } -export interface ISidecarWriteJob extends IEntityJob { - tags?: true; -} - export interface IDeferrableJob extends IEntityJob { deferred?: boolean; } @@ -306,12 +280,6 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { recipientId: string; } -export type IWorkflowJob = { - id: string; - trigger: WorkflowTrigger; - type: T; -}; - export interface IIntegrityJob { refreshOnly?: boolean; } @@ -647,7 +615,10 @@ export type JSONSchemaProperty = { required?: string[]; }; -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -export interface ClassConstructor extends Function { - new (...args: any[]): T; -} +export type ClassConstructor = T extends new (...args: infer R) => infer L + ? new (...args: R) => L + : new (...args: any[]) => unknown; + +export type ClassConstructorsToInstances[]> = { + [K in keyof T]: InstanceType | Mocked>; +}; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index ce8e614565..0d6f4b4eeb 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -324,7 +324,8 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe case Permission.WorkflowRead: case Permission.WorkflowUpdate: - case Permission.WorkflowDelete: { + case Permission.WorkflowDelete: + case Permission.WorkflowLogs: { return access.workflow.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/src/utils/bytes.ts b/server/src/utils/bytes.ts index 5e476f4dea..67e94c7aca 100644 --- a/server/src/utils/bytes.ts +++ b/server/src/utils/bytes.ts @@ -20,7 +20,7 @@ export function asHumanReadable(bytes: number, precision = 1): string { } } - return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`; + return `${remainder.toFixed(magnitude === 0 ? 0 : precision)} ${units[magnitude]}`; } // if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0f4d8775b6..5122b0a9d4 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -81,8 +81,6 @@ export const asUuid = (id: string | Expression) => sql`${id}::uu export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`; -export const asVector = (embedding: number[]) => sql`${`[${embedding}]`}::vector`; - export const unnest = (array: string[]) => sql>`unnest(array[${sql.join(array)}]::text[])`; export const removeUndefinedKeys = (update: T, template: unknown) => { diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index aea821d6d5..656e5bf441 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -712,10 +712,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig { : ['-cq:v', String(this.config.crf)]; } - getThreadOptions() { - return []; - } - getEncoderOptions(): string[] { const out = this.getOutputThreadOptions(); if (this.tune.strictGop) { diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 0e7a9e6cdc..a514007987 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -1,4 +1,4 @@ -import { INestApplication } from '@nestjs/common'; +import { BadRequestException, INestApplication } from '@nestjs/common'; import { ApiBodyOptions, DocumentBuilder, @@ -14,7 +14,7 @@ import path from 'node:path'; import picomatch from 'picomatch'; import parse from 'picomatch/lib/parse'; import { SystemConfig } from 'src/config'; -import { CLIP_MODEL_INFO, endpointTags, serverVersion } from 'src/constants'; +import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants'; import { extraModels } from 'src/decorators'; import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -111,6 +111,32 @@ export const handlePromiseError = (promise: Promise, logger: LoggingReposi promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack)); }; +export const findOrFail = async (find: () => Promise, entity: string): Promise> => { + const value = await find(); + if (!value) { + throw new BadRequestException(`${entity} not found`); + } + + return value; +}; + +export async function* batched(items: AsyncIterable, size = JOBS_ASSET_PAGINATION_SIZE): AsyncGenerator { + let batch: T[] = []; + + for await (const item of items) { + batch.push(item); + + if (batch.length >= size) { + yield batch; + batch = []; + } + } + + if (batch.length > 0) { + yield batch; + } +} + export interface OpenGraphTags { title: string; description: string; diff --git a/server/src/utils/transform.ts b/server/src/utils/transform.ts index aa1fe8b7fc..76d5247de3 100644 --- a/server/src/utils/transform.ts +++ b/server/src/utils/transform.ts @@ -227,7 +227,7 @@ export const transformOcrBoundingBox = ( const { points: transformedPoints, currentWidth, currentHeight } = transformPoints(points, edits, imageDimensions); // Reorder points to maintain semantic ordering (topLeft, topRight, bottomRight, bottomLeft) - const netRotation = edits.find((e) => e.action == AssetEditAction.Rotate)?.parameters.angle ?? 0 % 360; + const netRotation = edits.find((e) => e.action === AssetEditAction.Rotate)?.parameters.angle ?? 0 % 360; const reorderedPoints = reorderQuadPointsForRotation(transformedPoints, netRotation); const [p1, p2, p3, p4] = reorderedPoints; diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts index c892239c80..a3a9d56c42 100644 --- a/server/src/utils/workflow.ts +++ b/server/src/utils/workflow.ts @@ -6,6 +6,7 @@ export const triggerMap: Record = { [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], // [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], [WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1], + [WorkflowTrigger.AssetTagged]: [WorkflowType.AssetV1], }; export const getWorkflowTriggers = () => diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 88a2278898..7ac7c14978 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -28,19 +28,6 @@ export const authStub = { id: 'token-id', } as AuthSession, }), - user2: Object.freeze({ - user: { - id: 'user-2', - name: 'User 2', - email: 'user2@immich.cloud', - isAdmin: false, - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }, - session: { - id: 'token-id', - } as AuthSession, - }), adminWithElevatedPermission: Object.freeze({ user: authUser.admin, session: { diff --git a/server/test/fixtures/file.stub.ts b/server/test/fixtures/file.stub.ts index f63d2d10fb..e64b769496 100644 --- a/server/test/fixtures/file.stub.ts +++ b/server/test/fixtures/file.stub.ts @@ -6,13 +6,6 @@ export const fileStub = { originalName: 'asset_1.jpeg', size: 42, }), - livePhotoMotion: Object.freeze({ - uuid: 'live-photo-motion-asset', - originalPath: 'fake_path/asset_1.mp4', - checksum: Buffer.from('live photo file hash', 'utf8'), - originalName: 'asset_1.mp4', - size: 69, - }), photo: Object.freeze({ uuid: 'photo', originalPath: 'fake_path/photo1.jpeg', diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 77534fd6cb..4421a09801 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -451,28 +451,6 @@ export const videoInfoStub = { }, ], }), - videoStreamWithProfileLevel: Object.freeze({ - ...probeStubDefault, - videoStreams: [ - { - ...probeStubDefaultVideoStream[0], - codecName: 'h264', - profile: 100, - level: 40, - }, - ], - }), - audioStreamAAC: Object.freeze({ - ...probeStubDefault, - audioStreams: [ - { - index: 1, - codecName: 'aac', - profile: 2, - bitrate: 128_000, - }, - ], - }), }; interface SelectedStreams { diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index b335a0fba5..860189d9a9 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -29,6 +29,7 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { DuplicateRepository } from 'src/repositories/duplicate.repository'; import { EmailRepository } from 'src/repositories/email.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { IntegrityRepository } from 'src/repositories/integrity.repository'; @@ -76,7 +77,7 @@ import { UserTable } from 'src/schema/tables/user.table'; import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service'; import { MetadataService } from 'src/services/metadata.service'; import { SyncService } from 'src/services/sync.service'; -import { ClassConstructor, UploadFile } from 'src/types'; +import { ClassConstructor, ClassConstructorsToInstances, UploadFile } from 'src/types'; import { getConfig, updateConfig } from 'src/utils/config'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; @@ -88,29 +89,34 @@ import { Mocked } from 'vitest'; export const testAssetsDir = resolve(__dirname, '../../e2e/test-assets'); type MediumTestOptions = { - mock: ClassConstructor[]; - real: ClassConstructor[]; + mock: Array<(typeof BASE_SERVICE_DEPENDENCIES)[number]>; + real: Array<(typeof BASE_SERVICE_DEPENDENCIES)[number]>; database: Kysely; }; -export const newMediumService = (Service: ClassConstructor, options: MediumTestOptions) => { +type BaseServiceDeps = typeof BASE_SERVICE_DEPENDENCIES; + +export const newMediumService = >( + Service: S, + options: MediumTestOptions, +) => { const ctx = new MediumTestContext(Service, options); return { sut: ctx.sut, ctx }; }; -export class MediumTestContext { +export class MediumTestContext = ClassConstructor> { private repoCache: Record = {}; - private sutDeps: any[]; + private sutDeps: ClassConstructorsToInstances; - sut: S; + sut: InstanceType; database: Kysely; constructor( - Service: ClassConstructor, + Service: S, private options: MediumTestOptions, ) { this.sutDeps = this.makeDeps(options); - this.sut = new Service(...this.sutDeps); + this.sut = new Service(...this.sutDeps) as InstanceType; this.database = options.database; } @@ -128,7 +134,7 @@ export class MediumTestContext { throw new Error(`Real repository ${dep.name} is not a valid dependency`); } } - return (deps as ClassConstructor[]).map((dep) => { + return deps.map((dep) => { if (options.real.includes(dep)) { return this.get(dep); } @@ -136,10 +142,10 @@ export class MediumTestContext { if (options.mock.includes(dep)) { return newMockRepository(dep); } - }); + }) as unknown as ClassConstructorsToInstances; } - get(key: ClassConstructor): T { + get(key: T): InstanceType { if (!Object.hasOwn(this.repoCache, key.name)) { const real = newRealRepository(key, this.options.database); this.repoCache[key.name] = real; @@ -148,8 +154,8 @@ export class MediumTestContext { return this.repoCache[key.name]; } - getMock>(key: ClassConstructor): R { - const index = BASE_SERVICE_DEPENDENCIES.indexOf(key as any); + getMock>>(key: T): R { + const index = BASE_SERVICE_DEPENDENCIES.indexOf(key); if (index === -1 || !this.options.mock.includes(key)) { throw new Error(`getMock called with a key that is not a mock: ${key.name}`); } @@ -328,7 +334,7 @@ export class MediumTestContext { } } -export class SyncTestContext extends MediumTestContext { +export class SyncTestContext extends MediumTestContext { constructor(database: Kysely) { super(SyncService, { database, @@ -379,7 +385,7 @@ const mockStats = { birthtimeMs: 0, }; -export class ExifTestContext extends MediumTestContext { +export class ExifTestContext extends MediumTestContext { constructor(database: Kysely) { super(MetadataService, { database, @@ -431,7 +437,7 @@ export class ExifTestContext extends MediumTestContext { } } -const newRealRepository = (key: ClassConstructor, db: Kysely): T => { +const newRealRepository = (key: T, db: Kysely): InstanceType => { switch (key) { case AccessRepository: case AlbumRepository: @@ -440,6 +446,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case AssetRepository: case AssetEditRepository: case AssetJobRepository: + case DuplicateRepository: case IntegrityRepository: case MemoryRepository: case NotificationRepository: @@ -457,41 +464,41 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case UserRepository: case VersionHistoryRepository: case WorkflowRepository: { - return new key(db); + return new key(db) as InstanceType; } case ConfigRepository: case CryptoRepository: { - return new key(); + return new key() as InstanceType; } case DatabaseRepository: { - return new key(db, LoggingRepository.create(), new ConfigRepository()); + return new key(db, LoggingRepository.create(), new ConfigRepository()) as InstanceType; } case EmailRepository: { - return new key(LoggingRepository.create()); + return new key(LoggingRepository.create()) as InstanceType; } case MediaRepository: case MetadataRepository: { - return new key(LoggingRepository.create()); + return new key(LoggingRepository.create()) as InstanceType; } case PluginRepository: { - return new key(db, LoggingRepository.create()); + return new key(db, LoggingRepository.create()) as InstanceType; } case StorageRepository: { - return new key(LoggingRepository.create()); + return new key(LoggingRepository.create()) as InstanceType; } case TagRepository: { - return new key(db, LoggingRepository.create()); + return new key(db, LoggingRepository.create()) as InstanceType; } - case LoggingRepository as unknown as ClassConstructor: { - return new key() as unknown as T; + case LoggingRepository: { + return new key(undefined, undefined) as InstanceType; } default: { diff --git a/server/test/medium/responses.ts b/server/test/medium/responses.ts index 9b75bc85ec..e8509e339b 100644 --- a/server/test/medium/responses.ts +++ b/server/test/medium/responses.ts @@ -1,27 +1,6 @@ import { expect } from 'vitest'; export const errorDto = { - unauthorized: { - message: 'Authentication required', - }, - forbidden: { - message: expect.any(String), - }, - missingPermission: (permission: string) => ({ - message: `Missing required permission: ${permission}`, - }), - wrongPassword: { - message: 'Wrong password', - }, - invalidToken: { - message: 'Invalid user token', - }, - invalidShareKey: { - message: 'Invalid share key', - }, - invalidSharePassword: { - message: 'Invalid password', - }, badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), @@ -29,10 +8,4 @@ export const errorDto = { message: 'Validation failed', errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), }), - noPermission: { - message: expect.stringContaining('Not found or no'), - }, - incorrectLogin: { - message: 'Incorrect email or password', - }, }; diff --git a/server/test/medium/specs/services/album.service.spec.ts b/server/test/medium/specs/services/album.service.spec.ts new file mode 100644 index 0000000000..bc73007593 --- /dev/null +++ b/server/test/medium/specs/services/album.service.spec.ts @@ -0,0 +1,37 @@ +import { Kysely } from 'kysely'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { AlbumService } from 'src/services/album.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(AlbumService, { + database: db || defaultDatabase, + real: [AlbumRepository, UserRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AlbumService.name, () => { + describe('database triggers', () => { + it('should cascade delete an album when the owner is deleted', async () => { + const { ctx } = setup(); + const { user } = await ctx.newUser(); + await ctx.newAlbum({ ownerId: user.id }); + + await ctx.get(UserRepository).delete({ id: user.id }, true); + + await expect(ctx.database.selectFrom('album').selectAll().execute()).resolves.toEqual([]); + await expect(ctx.database.selectFrom('album_user').selectAll().execute()).resolves.toEqual([]); + }); + }); +}); diff --git a/server/test/medium/specs/services/duplicate.service.spec.ts b/server/test/medium/specs/services/duplicate.service.spec.ts new file mode 100644 index 0000000000..6afae277cb --- /dev/null +++ b/server/test/medium/specs/services/duplicate.service.spec.ts @@ -0,0 +1,622 @@ +import { Kysely } from 'kysely'; +import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; +import { AssetStatus, AssetVisibility } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { DuplicateRepository } from 'src/repositories/duplicate.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; +import { DB } from 'src/schema'; +import { DuplicateService } from 'src/services/duplicate.service'; +import { clearConfigCache } from 'src/utils/config'; +import { MediumTestContext, newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { sut, ctx } = newMediumService(DuplicateService, { + database: db || defaultDatabase, + real: [ + AccessRepository, + AlbumRepository, + AssetRepository, + ConfigRepository, + DuplicateRepository, + SystemMetadataRepository, + TagRepository, + ], + mock: [EventRepository, JobRepository, LoggingRepository], + }); + + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + + return { sut, ctx }; +}; + +const newDuplicateAsset = async ( + ctx: MediumTestContext, + { + ownerId, + duplicateId, + ...dto + }: { ownerId: string; duplicateId?: string; isFavorite?: boolean; visibility?: AssetVisibility }, + exif: { rating?: number; description?: string; latitude?: number; longitude?: number; fileSizeInByte?: number } = {}, +) => { + const { asset } = await ctx.newAsset({ ownerId, duplicateId, ...dto }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 1000, ...exif }); + return asset; +}; + +const newTag = async (ctx: MediumTestContext, userId: string, value: string) => { + return ctx.get(TagRepository).create({ userId, value }); +}; + +const expectSuccess = (results: BulkIdResponseDto[], duplicateId: string) => { + expect(results).toEqual([{ id: duplicateId, success: true }]); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +beforeEach(() => { + clearConfigCache(); +}); + +describe(DuplicateService.name, () => { + describe('getDuplicates', () => { + it('should return an empty list when the user has no duplicates', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect(sut.getDuplicates(auth)).resolves.toEqual([]); + }); + + it('should return a duplicate group with a suggested keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const small = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { fileSizeInByte: 1000 }); + const large = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { fileSizeInByte: 2000 }); + + const auth = factory.auth({ user: { id: user.id } }); + const duplicates = await sut.getDuplicates(auth); + + expect(duplicates).toHaveLength(1); + expect(duplicates[0].duplicateId).toBe(duplicateId); + expect(duplicates[0].assets.map(({ id }) => id).sort()).toEqual([small.id, large.id].sort()); + // largest file size wins + expect(duplicates[0].suggestedKeepAssetIds).toEqual([large.id]); + }); + + it('should not return duplicates owned by someone else', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + + const auth = factory.auth({ user: { id: other.id } }); + await expect(sut.getDuplicates(auth)).resolves.toEqual([]); + }); + + it('should clear the duplicateId of a singleton group', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect(sut.getDuplicates(auth)).resolves.toEqual([]); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ duplicateId: null }); + }); + }); + + describe('resolve', () => { + it('should throw when the duplicate group does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect( + sut.resolve(auth, { groups: [{ duplicateId: factory.uuid(), keepAssetIds: [], trashAssetIds: [] }] }), + ).rejects.toThrow('Not found or no duplicate.delete access'); + }); + + it('should throw when the duplicate group belongs to another user', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + const asset2 = await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + + const auth = factory.auth({ user: { id: other.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }] }), + ).rejects.toThrow('Not found or no duplicate.delete access'); + }); + + it('should clear the duplicateId of the keeper and trash the rest', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + const assetRepo = ctx.get(AssetRepository); + await expect(assetRepo.getById(keeper.id)).resolves.toMatchObject({ + duplicateId: null, + deletedAt: null, + }); + await expect(assetRepo.getById(trashed.id)).resolves.toMatchObject({ + duplicateId: null, + status: AssetStatus.Trashed, + }); + }); + + it('should emit AssetTrashAll for the trashed assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }), + duplicateId, + ); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetTrashAll', { + assetIds: [trashed.id], + userId: user.id, + }); + }); + + it('should fail when an asset is in both keepAssetIds and trashAssetIds', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'An asset cannot be in both keepAssetIds and trashAssetIds', + }, + ]); + }); + + it('should fail when the group is only partially covered', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const asset2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', + }, + ]); + }); + + it('should ignore ids that are not members of the group', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const outsider = await newDuplicateAsset(ctx, { ownerId: user.id }); + + // the outsider is filtered out, which leaves asset2 uncovered + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsider.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', + }, + ]); + + await expect(ctx.get(AssetRepository).getById(outsider.id)).resolves.toMatchObject({ deletedAt: null }); + }); + + it('should trash every asset when no keepers are given', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const asset2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }], + }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + const assetRepo = ctx.get(AssetRepository); + for (const { id } of [asset1, asset2]) { + await expect(assetRepo.getById(id)).resolves.toMatchObject({ + duplicateId: null, + status: AssetStatus.Trashed, + }); + } + }); + + it('should fail without permission to delete an asset owned by someone else', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const mine = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const theirs = await newDuplicateAsset(ctx, { ownerId: other.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [mine.id], trashAssetIds: [theirs.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.NO_PERMISSION, + errorMessage: 'No permission to delete assets', + }, + ]); + + await expect(ctx.get(AssetRepository).getById(theirs.id)).resolves.toMatchObject({ deletedAt: null }); + }); + + it('should resolve several groups in one request', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId1 = factory.uuid(); + const duplicateId2 = factory.uuid(); + + const keeper1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId1 }); + const trashed1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId1 }); + const keeper2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId2 }); + const trashed2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId2 }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { + groups: [ + { duplicateId: duplicateId1, keepAssetIds: [keeper1.id], trashAssetIds: [trashed1.id] }, + { duplicateId: duplicateId2, keepAssetIds: [keeper2.id], trashAssetIds: [trashed2.id] }, + ], + }), + ).resolves.toEqual([ + { id: duplicateId1, success: true }, + { id: duplicateId2, success: true }, + ]); + + const assetRepo = ctx.get(AssetRepository); + await expect(assetRepo.getById(trashed1.id)).resolves.toMatchObject({ status: AssetStatus.Trashed }); + await expect(assetRepo.getById(trashed2.id)).resolves.toMatchObject({ status: AssetStatus.Trashed }); + }); + + it('should report per-group failures without abandoning the valid groups', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const goodId = factory.uuid(); + const badId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: goodId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: goodId }); + // the bad group exists (so access passes) but is left partially covered + const badKeeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: badId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: badId }); + + const auth = factory.auth({ user: { id: user.id } }); + const results = await sut.resolve(auth, { + groups: [ + { duplicateId: goodId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }, + { duplicateId: badId, keepAssetIds: [badKeeper.id], trashAssetIds: [] }, + ], + }); + + expect(results).toEqual([ + { id: goodId, success: true }, + { + id: badId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', + }, + ]); + + // the good group still went through + await expect(ctx.get(AssetRepository).getById(trashed.id)).resolves.toMatchObject({ + status: AssetStatus.Trashed, + }); + }); + + describe('metadata merging', () => { + it('should merge isFavorite into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: false }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: true }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + await expect(ctx.get(AssetRepository).getById(keeper.id)).resolves.toMatchObject({ + isFavorite: true, + duplicateId: null, + }); + }); + + it('should merge the most restrictive visibility into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeperAsset = await newDuplicateAsset(ctx, { + ownerId: user.id, + duplicateId, + visibility: AssetVisibility.Timeline, + }); + const trashedAsset = await newDuplicateAsset(ctx, { + ownerId: user.id, + duplicateId, + visibility: AssetVisibility.Archive, + }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeperAsset.id], trashAssetIds: [trashedAsset.id] }], + }), + duplicateId, + ); + + await expect(ctx.get(AssetRepository).getById(keeperAsset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Archive, + duplicateId: null, + }); + }); + + it('should merge the highest rating into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { rating: 1 }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { rating: 5 }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo).toMatchObject({ rating: 5 }); + }); + + it('should merge distinct description lines into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { description: 'from keeper' }); + const trashed = await newDuplicateAsset( + ctx, + { ownerId: user.id, duplicateId }, + { description: 'from trashed' }, + ); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo?.description?.split('\n').sort()).toEqual(['from keeper', 'from trashed']); + }); + + it('should merge a location that both assets agree on', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset( + ctx, + { ownerId: user.id, duplicateId }, + { latitude: 40.7128, longitude: -74.006 }, + ); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo).toMatchObject({ latitude: 40.7128, longitude: -74.006 }); + }); + + it('should not merge a location the assets disagree on', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { latitude: 1, longitude: 1 }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { latitude: 2, longitude: 2 }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo).toMatchObject({ latitude: 1, longitude: 1 }); + }); + + it('should add the keeper to every album the group belonged to', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const { album: album1 } = await ctx.newAlbum({ ownerId: user.id }, [keeper.id]); + const { album: album2 } = await ctx.newAlbum({ ownerId: user.id }, [trashed.id]); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const albumRepo = ctx.get(AlbumRepository); + await expect(albumRepo.getAssetIds(album1.id, [keeper.id])).resolves.toEqual(new Set([keeper.id])); + await expect(albumRepo.getAssetIds(album2.id, [keeper.id])).resolves.toEqual(new Set([keeper.id])); + }); + + it('should merge the tags of the group onto the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const tag1 = await newTag(ctx, user.id, 'tag1'); + const tag2 = await newTag(ctx, user.id, 'tag2'); + await ctx.newTagAsset({ tagIds: [tag1.id], assetIds: [keeper.id] }); + await ctx.newTagAsset({ tagIds: [tag2.id], assetIds: [trashed.id] }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { tags: true }); + expect(result?.tags?.map(({ id }) => id).sort()).toEqual([tag1.id, tag2.id].sort()); + }); + + it('should not merge metadata when there is more than one keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: false }); + const keeper2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: true }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper1.id, keeper2.id], trashAssetIds: [] }], + }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + await expect(ctx.get(AssetRepository).getById(keeper1.id)).resolves.toMatchObject({ + isFavorite: false, + duplicateId: null, + }); + }); + }); + + describe('when trash is disabled', () => { + it('should delete instead of trash', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const config = await ctx.getConfig(); + await ctx.updateConfig({ ...config, trash: { ...config.trash, enabled: false } }); + clearConfigCache(); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + await expect(ctx.get(AssetRepository).getById(trashed.id)).resolves.toMatchObject({ + duplicateId: null, + status: AssetStatus.Deleted, + }); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetDeleteAll', { + assetIds: [trashed.id], + userId: user.id, + }); + }); + }); + }); +}); diff --git a/server/test/medium/specs/services/user.service.spec.ts b/server/test/medium/specs/services/user.service.spec.ts index 2eac06d674..85ab9a8c0d 100644 --- a/server/test/medium/specs/services/user.service.spec.ts +++ b/server/test/medium/specs/services/user.service.spec.ts @@ -1,6 +1,6 @@ import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; -import { ImmichEnvironment, JobName, JobStatus } from 'src/enum'; +import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { EventRepository } from 'src/repositories/event.repository'; @@ -10,10 +10,17 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos import { UserRepository } from 'src/repositories/user.repository'; import { DB } from 'src/schema'; import { UserService } from 'src/services/user.service'; +import { HumanReadableSize } from 'src/utils/bytes'; import { mediumFactory, newMediumService } from 'test/medium.factory'; import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; +const userLicense = { + licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4', + activationKey: + 'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw', +}; + let defaultDatabase: Kysely; const setup = (db?: Kysely) => { @@ -38,9 +45,10 @@ describe(UserService.name, () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); const user = mediumFactory.userInsert(); - await expect(sut.createUser({ name: user.name, email: user.email })).resolves.toEqual( - expect.objectContaining({ name: user.name, email: user.email }), - ); + const created = await sut.createUser({ name: user.name, email: user.email }); + expect(created).toEqual(expect.objectContaining({ name: user.name, email: user.email })); + + await expect(sut.get(created.id)).resolves.toMatchObject({ name: user.name, email: user.email }); }); it('should reject user with duplicate email', async () => { @@ -97,6 +105,38 @@ describe(UserService.name, () => { expect((result as any).password).toBeUndefined(); }); + + it('should not expose private fields', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + await expect(sut.get(user.id)).resolves.not.toMatchObject({ + shouldChangePassword: expect.anything(), + storageLabel: expect.anything(), + }); + }); + }); + + describe('getMe', () => { + it('should get my user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + await expect(sut.getMe(auth)).resolves.toEqual( + expect.objectContaining({ id: user.id, email: user.email, quotaUsageInBytes: 0 }), + ); + }); + + it('should include license info', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await sut.setLicense(auth, userLicense); + + await expect(sut.getMe(auth)).resolves.toMatchObject({ license: userLicense }); + }); }); describe('updateMe', () => { @@ -109,25 +149,169 @@ describe(UserService.name, () => { expect(before.updatedAt).toBeDefined(); expect(after.updatedAt).toBeDefined(); expect(before.updatedAt).not.toEqual(after.updatedAt); + + await expect(sut.getMe(auth)).resolves.toMatchObject({ + name: `${before.name} Updated`, + updatedAt: after.updatedAt, + }); + }); + + it('should update the name', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { name: 'Name' }; + + await expect(sut.updateMe(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMe(auth)).resolves.toMatchObject(dto); + }); + + it('should update the email', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { email: 'updated@immich.cloud' }; + + await expect(sut.updateMe(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMe(auth)).resolves.toMatchObject(dto); + }); + + it('should not allow an email that is already taken', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user2.id } }); + + await expect(sut.updateMe(auth, { email: user1.email })).rejects.toThrow('Email is not available'); + }); + + it('should update the avatar color', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { avatarColor: UserAvatarColor.Blue }; + + await expect(sut.updateMe(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMe(auth)).resolves.toMatchObject(dto); + }); + + it('should clear shouldChangePassword when the password is updated', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser({ shouldChangePassword: true }); + const auth = factory.auth({ user: { id: user.id } }); + + await expect(sut.updateMe(auth, { password: 'super-secret' })).resolves.toMatchObject({ + shouldChangePassword: false, + }); + await expect(sut.getMe(auth)).resolves.toMatchObject({ shouldChangePassword: false }); + }); + }); + + describe('updateMyPreferences', () => { + it('should update memories enabled', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { memories: { enabled: false } }; + + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ memories: { enabled: true } }); + await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto); + }); + + it('should update the download archive size', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { download: { archiveSize: 1_234_567 } }; + + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ + download: { archiveSize: 4 * HumanReadableSize.GiB }, + }); + await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto); + }); + + it('should update download include embedded videos', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { download: { includeEmbeddedVideos: true } }; + + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ + download: { includeEmbeddedVideos: false }, + }); + await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto); + }); + + it('should update the minimum face count', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const dto = { people: { minimumFaces: 2 } }; + + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ people: { minimumFaces: 3 } }); + await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto); + await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto); }); }); describe('setLicense', () => { it('should set a license', async () => { - const license = { - licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4', - activationKey: - 'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw', - }; const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const auth = factory.auth({ user: { id: user.id } }); await expect(sut.getLicense(auth)).rejects.toThrowError(); - const after = await sut.setLicense(auth, license); - expect(after.licenseKey).toEqual(license.licenseKey); - expect(after.activationKey).toEqual(license.activationKey); + const after = await sut.setLicense(auth, userLicense); + expect(after.licenseKey).toEqual(userLicense.licenseKey); + expect(after.activationKey).toEqual(userLicense.activationKey); const response = await sut.getLicense(auth); expect(response).toEqual(after); + await expect(sut.getMe(auth)).resolves.toMatchObject({ license: after }); + }); + + it('should reject a license key that does not start with IMCL-', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect( + sut.setLicense(auth, { + licenseKey: 'IMSV-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', + activationKey: 'activationKey', + }), + ).rejects.toThrow('Invalid license key'); + }); + + it('should reject an invalid activation key', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect( + sut.setLicense(auth, { ...userLicense, activationKey: `invalid${userLicense.activationKey}` }), + ).rejects.toThrow('Invalid license key'); + }); + }); + + describe('deleteLicense', () => { + it('should delete the license', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await sut.setLicense(auth, userLicense); + await sut.deleteLicense(auth); + + await expect(sut.getLicense(auth)).rejects.toThrowError(); }); }); diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index 8bd65af73f..dfb4868c0d 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -24,7 +24,7 @@ import { getKyselyDB } from 'test/utils'; let isInitialized = false; -class WorkflowTestContext extends MediumTestContext { +class WorkflowTestContext extends MediumTestContext { constructor(database: Kysely) { super(WorkflowExecutionService, { database, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index ae83727d70..545c05e881 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,7 +1,7 @@ -import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database'; +import { AuthApiKey, AuthSharedLink, AuthUser, Library, UserAdmin } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto'; -import { AssetFileType, Permission, UserStatus } from 'src/enum'; +import { Permission, UserStatus } from 'src/enum'; import { v4, v7 } from 'uuid'; import { expect } from 'vitest'; @@ -168,31 +168,6 @@ const versionHistoryFactory = () => ({ version: '1.123.45', }); -const assetSidecarWriteFactory = () => { - const id = newUuid(); - return { - id, - originalPath: '/path/to/original-path.jpg.xmp', - tags: [], - files: [ - { - id: newUuid(), - path: '/path/to/original-path.jpg.xmp', - type: AssetFileType.Sidecar, - isEdited: false, - }, - ], - exifInfo: { - assetId: id, - description: 'this is a description', - latitude: 12, - longitude: 12, - dateTimeOriginal: '2023-11-22T04:56:12.196Z', - timeZone: 'UTC-6', - } as unknown as Exif, - }; -}; - const assetOcrFactory = ( ocr: { id?: string; @@ -236,9 +211,6 @@ export const factory = { library: libraryFactory, queueStatistics: queueStatisticsFactory, versionHistory: versionHistoryFactory, - jobAssets: { - sidecarWrite: assetSidecarWriteFactory, - }, uuid: newUuid, buffer: () => Buffer.from('this is a fake buffer'), date: newDate, diff --git a/server/test/utils.ts b/server/test/utils.ts index 2f32f3b412..e1920a4a79 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -553,43 +553,6 @@ export const mockDuplex = return duplex; }; -export const mockFork = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => { - const stdoutStream = new Readable({ - read() { - this.push(stdout); // write mock data to stdout - this.push(null); // end stream - }, - }); - - return { - stdout: stdoutStream, - stderr: new Readable({ - read() { - this.push(stderr); // write mock data to stderr - this.push(null); // end stream - }, - }), - stdin: new Writable({ - write(chunk, encoding, callback) { - callback(); - }, - }), - exitCode, - on: vitest.fn((event, callback: any) => { - if (event === 'close') { - stdoutStream.once('end', () => callback(0)); - } - if (event === 'error' && error) { - stdoutStream.once('end', () => callback(error)); - } - if (event === 'exit') { - stdoutStream.once('end', () => callback(exitCode)); - } - }), - kill: vitest.fn(), - } as unknown as ChildProcessWithoutNullStreams; -}); - export async function* makeStream(items: T[] = []): AsyncGenerator { for (const item of items) { await Promise.resolve(); diff --git a/web/eslint.config.js b/web/eslint.config.js index de378d11c7..f7fd82d751 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -10,15 +10,13 @@ import parser from 'svelte-eslint-parser'; import typescriptEslint from 'typescript-eslint'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import './lint-env.js'; export default typescriptEslint.config( ...eslintPluginSvelte.configs.recommended, eslintPluginUnicorn.configs.recommended, js.configs.recommended, + prettier, { plugins: { tscompat: tslintPluginCompat, @@ -28,7 +26,7 @@ export default typescriptEslint.config( 'error', { browserslist: fs - .readFileSync(path.join(__dirname, '.browserslistrc'), 'utf8') + .readFileSync(path.join(import.meta.dirname, '.browserslistrc'), 'utf8') .split('\n') .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')), @@ -39,7 +37,7 @@ export default typescriptEslint.config( parser, parserOptions: { project: ['./tsconfig.json'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, // ignores: ['**/service-worker/**'], @@ -71,6 +69,7 @@ export default typescriptEslint.config( '**/yarn.lock', '**/svelte.config.js', 'eslint.config.js', + 'lint-env.js', 'tailwind.config.js', 'coverage', 'vite.config.ts', @@ -95,7 +94,7 @@ export default typescriptEslint.config( parserOptions: { extraFileExtensions: ['.svelte'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, project: ['./tsconfig.json'], }, }, @@ -158,6 +157,7 @@ export default typescriptEslint.config( 'svelte/button-has-type': 'error', 'object-shorthand': ['error', 'always'], 'svelte/no-navigation-without-resolve': 'off', + eqeqeq: 'error', }, }, { diff --git a/web/lint-env.js b/web/lint-env.js new file mode 100644 index 0000000000..3394cdb783 --- /dev/null +++ b/web/lint-env.js @@ -0,0 +1,5 @@ +// Must be imported before eslint-plugin-svelte / svelte-eslint-parser. +// Enables svelte-eslint-parser's ts.sys hook so typescript-eslint can reuse +// its TypeScript program across .svelte files instead of rebuilding it per +// file (~4x faster lint). See https://github.com/sveltejs/eslint-plugin-svelte/issues/1552 +process.env.SVELTE_ESLINT_PARSER_EXPERIMENTAL_TS_SYS_HOOK ??= '1'; diff --git a/web/package.json b/web/package.json index 338e2f5abe..ca0274df88 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", - "@immich/ui": "^0.83.0", + "@immich/ui": "^0.85.0", "@mapbox/mapbox-gl-rtl-text": "0.4.0", "@mdi/js": "^7.4.47", "@noble/hashes": "^2.2.0", @@ -90,6 +90,7 @@ "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", "@types/qrcode": "^1.5.5", + "@typescript/native": "npm:typescript@^7.0.2", "@vitest/coverage-v8": "^4.0.0", "dotenv": "^17.0.0", "eslint": "^10.2.1", @@ -107,9 +108,8 @@ "rollup-plugin-visualizer": "^7.0.0", "svelte": "5.56.8", "svelte-check": "^4.4.6", - "svelte-eslint-parser": "^1.3.3", + "svelte-eslint-parser": "^1.8.1", "tailwindcss": "^4.2.4", - "@typescript/native": "npm:typescript@^7.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2", "typescript-eslint": "^8.45.0", "vite": "^8.0.0", diff --git a/web/src/lib/__mocks__/jsdom-url.mock.ts b/web/src/lib/__mocks__/jsdom-url.mock.ts deleted file mode 100644 index d65da98141..0000000000 --- a/web/src/lib/__mocks__/jsdom-url.mock.ts +++ /dev/null @@ -1,9 +0,0 @@ -const createObjectURLMock = vi.fn(); - -// eslint-disable-next-line unicorn/no-top-level-side-effects -Object.defineProperty(URL, 'createObjectURL', { - writable: true, - value: createObjectURLMock, -}); - -export { createObjectURLMock }; diff --git a/web/src/lib/actions/use-actions.ts b/web/src/lib/actions/use-actions.ts index 622615358d..0b09549a03 100644 --- a/web/src/lib/actions/use-actions.ts +++ b/web/src/lib/actions/use-actions.ts @@ -40,7 +40,7 @@ export function useActions(node: HTMLElement | SVGElement, actions: ActionArray) return { update(actions: ActionArray) { - if ((actions?.length || 0) != actionReturns.length) { + if ((actions?.length || 0) !== actionReturns.length) { throw new Error('You must not change the length of an actions array.'); } diff --git a/web/src/lib/attachments/drag-and-drop.svelte.ts b/web/src/lib/attachments/drag-and-drop.svelte.ts deleted file mode 100644 index 28afd25615..0000000000 --- a/web/src/lib/attachments/drag-and-drop.svelte.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Attachment } from 'svelte/attachments'; - -export interface DragAndDropOptions { - index: number; - onDragStart?: (index: number) => void; - onDragEnter?: (index: number) => void; - onDrop?: (e: DragEvent, index: number) => void; - onDragEnd?: () => void; - isDragging?: boolean; - isDragOver?: boolean; -} - -export function dragAndDrop(options: DragAndDropOptions): Attachment { - return (node: Element) => { - const element = node as HTMLElement; - const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options; - - const isFormElement = (el: HTMLElement) => { - return ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName); - }; - - const handleDragStart = (e: DragEvent) => { - // Prevent drag if it originated from an input, textarea, or select element - const target = e.target as HTMLElement; - if (isFormElement(target)) { - e.preventDefault(); - return; - } - onDragStart?.(index); - }; - - const handleDragEnter = () => { - onDragEnter?.(index); - }; - - const handleDragOver = (e: DragEvent) => { - e.preventDefault(); - }; - - const handleDrop = (e: DragEvent) => { - onDrop?.(e, index); - }; - - const handleDragEnd = () => { - onDragEnd?.(); - }; - - // Disable draggable when focusing on form elements (fixes Firefox input interaction) - const handleFocusIn = (e: FocusEvent) => { - const target = e.target as HTMLElement; - if (isFormElement(target)) { - element.setAttribute('draggable', 'false'); - } - }; - - const handleFocusOut = (e: FocusEvent) => { - const target = e.target as HTMLElement; - if (isFormElement(target)) { - element.setAttribute('draggable', 'true'); - } - }; - - // Update classes based on drag state - const updateClasses = (dragging: boolean, dragOver: boolean) => { - // Remove all drag-related classes first - element.classList.remove('opacity-50', 'border-light-500', 'border-solid'); - - // Add back only the active ones - if (dragging) { - element.classList.add('opacity-50'); - } - - if (dragOver) { - element.classList.add('border-light-500', 'border-solid'); - element.classList.remove('border-transparent'); - } else { - element.classList.add('border-transparent'); - } - }; - - element.setAttribute('draggable', 'true'); - element.setAttribute('role', 'button'); - element.setAttribute('tabindex', '0'); - - element.addEventListener('dragstart', handleDragStart); - element.addEventListener('dragenter', handleDragEnter); - element.addEventListener('dragover', handleDragOver); - element.addEventListener('drop', handleDrop); - element.addEventListener('dragend', handleDragEnd); - element.addEventListener('focusin', handleFocusIn); - element.addEventListener('focusout', handleFocusOut); - - updateClasses(isDragging || false, isDragOver || false); - - return () => { - element.removeEventListener('dragstart', handleDragStart); - element.removeEventListener('dragenter', handleDragEnter); - element.removeEventListener('dragover', handleDragOver); - element.removeEventListener('drop', handleDrop); - element.removeEventListener('dragend', handleDragEnd); - element.removeEventListener('focusin', handleFocusIn); - element.removeEventListener('focusout', handleFocusOut); - }; - }; -} diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte index 65d115179e..c4e20081dd 100644 --- a/web/src/lib/components/SchemaConfiguration.svelte +++ b/web/src/lib/components/SchemaConfiguration.svelte @@ -1,6 +1,7 @@ + + { + if (option && !tagIds.includes(option.value)) { + tagIds.push(option.value); + } + }} + label={$t('tags')} + defaultFirstOption + options={tags.map((tag) => ({ label: tag.value, value: tag.id }))} + placeholder={$t('search_tags')} +/> + +
+ {#each tagIds as id, index (id)} + t.id === id)?.name ?? id} onRemove={() => tagIds.splice(index, 1)} /> + {/each} +
diff --git a/web/src/lib/components/asset-viewer/ActivityViewer.svelte b/web/src/lib/components/asset-viewer/ActivityViewer.svelte index 803495c8bb..01c970e627 100644 --- a/web/src/lib/components/asset-viewer/ActivityViewer.svelte +++ b/web/src/lib/components/asset-viewer/ActivityViewer.svelte @@ -99,7 +99,7 @@ }; $effect(() => { - if (assetId && previousAssetId != assetId) { + if (assetId && previousAssetId !== assetId) { previousAssetId = assetId; } }); @@ -168,7 +168,7 @@ {/if} - {#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1} + {#if (index !== activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
{/if}
- {#if (index != activityManager.activities.length - 1 && isTenMinutesApart(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1} + {#if (index !== activityManager.activities.length - 1 && isTenMinutesApart(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
{/if} - {#if $slideshowState != SlideshowState.None} + {#if $slideshowState !== SlideshowState.None}
diff --git a/web/src/lib/components/asset-viewer/VideoRemoteViewer.svelte b/web/src/lib/components/asset-viewer/VideoRemoteViewer.svelte index 2ca123b879..040a22f04f 100644 --- a/web/src/lib/components/asset-viewer/VideoRemoteViewer.svelte +++ b/web/src/lib/components/asset-viewer/VideoRemoteViewer.svelte @@ -76,7 +76,7 @@ poster
- {#if castManager.castState == CastState.BUFFERING} + {#if castManager.castState === CastState.BUFFERING}
@@ -85,9 +85,9 @@ color="primary" shape="round" variant="ghost" - icon={castManager.castState == CastState.PLAYING ? mdiPause : mdiPlay} + icon={castManager.castState === CastState.PLAYING ? mdiPause : mdiPlay} onclick={() => handlePlayPauseButton()} - aria-label={castManager.castState == CastState.PLAYING ? 'Pause' : 'Play'} + aria-label={castManager.castState === CastState.PLAYING ? 'Pause' : 'Play'} /> {/if} diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte deleted file mode 100644 index 9817870d93..0000000000 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -
  • - -
  • diff --git a/web/src/lib/components/faces-page/PersonSidePanel.svelte b/web/src/lib/components/faces-page/PersonSidePanel.svelte index eaeabcc921..759bd03bce 100644 --- a/web/src/lib/components/faces-page/PersonSidePanel.svelte +++ b/web/src/lib/components/faces-page/PersonSidePanel.svelte @@ -357,7 +357,7 @@
    {/if}
    - {#if face.person != null} + {#if face.person !== null}
    { - if (lat != null && lng != null) { + if (lat !== undefined && lng !== undefined) { onUpdate(lat, lng); } }; diff --git a/web/src/lib/components/shared-components/map/Map.svelte b/web/src/lib/components/shared-components/map/Map.svelte index 52805d9b96..9811a8a7be 100644 --- a/web/src/lib/components/shared-components/map/Map.svelte +++ b/web/src/lib/components/shared-components/map/Map.svelte @@ -287,7 +287,7 @@ if (previousStyle) { // Preserves the custom map markers from the previous style when the theme is switched // Required until https://github.com/dimfeld/svelte-maplibre/issues/146 is fixed - const customLayers = previousStyle.layers.filter((l) => l.type == 'fill' && l.source == 'geojson'); + const customLayers = previousStyle.layers.filter((l) => l.type === 'fill' && l.source === 'geojson'); const layers = nextStyle.layers.concat(customLayers); const sources = nextStyle.sources; diff --git a/web/src/lib/elements/FormatMessage.svelte b/web/src/lib/elements/FormatMessage.svelte index 53f72451cc..cc097d783f 100644 --- a/web/src/lib/elements/FormatMessage.svelte +++ b/web/src/lib/elements/FormatMessage.svelte @@ -23,7 +23,7 @@ let { key, values = {}, children }: Props = $props(); const getLocale = (locale?: string | null) => { - if (locale == null) { + if (!locale) { throw new Error('Cannot format a message without first setting the initial locale.'); } diff --git a/web/src/lib/i18n.spec.ts b/web/src/lib/i18n.spec.ts index ebf2fdfa3b..bac5dba4b7 100644 --- a/web/src/lib/i18n.spec.ts +++ b/web/src/lib/i18n.spec.ts @@ -6,7 +6,7 @@ describe('i18n', () => { const languageFiles = readdirSync('../i18n').sort(); for (const filename of languageFiles) { test(`${filename} should have a loader`, async () => { - if (!filename.endsWith('.json') || filename == 'package.json') { + if (!filename.endsWith('.json') || filename === 'package.json') { return; } diff --git a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts index fdbc86a7db..e3e72aec82 100644 --- a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts +++ b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts @@ -55,7 +55,7 @@ export abstract class VirtualScrollManager { } #setHeaderHeight(value: number) { - if (this.#headerHeight == value) { + if (this.#headerHeight === value) { return false; } this.#headerHeight = value; @@ -67,7 +67,7 @@ export abstract class VirtualScrollManager { } #setGap(value: number) { - if (this.#gap == value) { + if (this.#gap === value) { return false; } this.#gap = value; @@ -79,7 +79,7 @@ export abstract class VirtualScrollManager { } #setRowHeight(value: number) { - if (this.#rowHeight == value) { + if (this.#rowHeight === value) { return false; } this.#rowHeight = value; diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index 9704af420b..23ddc1f3ea 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -7,9 +7,6 @@ import { getDimensions } from '$lib/utils/asset-utils'; import { normalizeTransformEdits } from '$lib/utils/editor'; import { handleError } from '$lib/utils/handle-error'; -export type CropAspectRatio = - '1:1' | '16:9' | '4:3' | '3:2' | '7:5' | '9:16' | '3:4' | '2:3' | '5:7' | 'free' | 'reset'; - type Region = { x: number; y: number; diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 3bd921a657..70a53368fd 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -47,11 +47,6 @@ export interface Viewport { height: number; } -export type ViewportXY = Viewport & { - x: number; - y: number; -}; - export interface AddAsset { type: 'add'; values: TimelineAsset[]; @@ -82,12 +77,6 @@ export type ScrubberMonth = { title: string; }; -export type TimelineManagerLayoutOptions = { - rowHeight?: number; - headerHeight?: number; - gap?: number; -}; - export interface UpdateGeometryOptions { invalidateHeight: boolean; noDefer?: boolean; diff --git a/web/src/lib/managers/timeline-manager/utils.svelte.ts b/web/src/lib/managers/timeline-manager/utils.svelte.ts index efc94206ea..377f841f98 100644 --- a/web/src/lib/managers/timeline-manager/utils.svelte.ts +++ b/web/src/lib/managers/timeline-manager/utils.svelte.ts @@ -1,6 +1,5 @@ import type { TimelineAsset } from './types'; -export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset); export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset)); export function filterIsInOrNearViewport(items: T[]) { diff --git a/web/src/lib/modals/AppDownloadModal.svelte b/web/src/lib/modals/AppDownloadModal.svelte index 84d7630909..01a998bf5c 100644 --- a/web/src/lib/modals/AppDownloadModal.svelte +++ b/web/src/lib/modals/AppDownloadModal.svelte @@ -1,5 +1,5 @@ diff --git a/web/src/lib/modals/WorkflowLogsModal.svelte b/web/src/lib/modals/WorkflowLogsModal.svelte new file mode 100644 index 0000000000..b897d4b437 --- /dev/null +++ b/web/src/lib/modals/WorkflowLogsModal.svelte @@ -0,0 +1,171 @@ + + + + + {#if workflow.logging} + + + {$t('date')} + {$t('result')} + + + {#each entries as entry (entry.id)} + + + +

    + {DateTime.fromISO(entry.at).toLocaleString(DateTime.DATETIME_MED, { + locale: $locale, + })} +

    + {#if entry.triggerDataId} + + + + {/if} +
    +
    + + + {#if entry.result === WorkflowResult.Completed} +

    + {$t('workflow_logging_completed')} +

    + {:else if entry.result === WorkflowResult.Halted} +

    + {#if entry.lastStep} + {$t('workflow_logging_halted_step', { values: { step: entry.lastStep.index + 1 } })} + {:else} + {$t('workflow_logging_halted')} + {/if} +

    + {:else} +

    + {#if entry.lastStep} + {$t('workflow_logging_error_step', { values: { step: entry.lastStep.index + 1 } })} + {:else} + {$t('error')} + {/if} +

    + {/if} +
    +
    +
    + {/each} + {#if hasNext} +
    ...
    + {/if} +
    +
    +
    +